Flutter Localization Caching Strategies: Optimize Translation Loading
Loading translations efficiently is crucial for app performance. This guide covers caching strategies to make your Flutter localization fast and responsive, whether translations come from local files or remote servers.
Why Cache Translations?
Without caching, your app might:
- Reload translations on every screen navigation
- Hit the network repeatedly for the same strings
- Block the UI while fetching translations
- Fail offline when remote translations aren't available
With proper caching:
- Translations load instantly after first fetch
- App works offline with cached translations
- Network requests are minimized
- Memory is used efficiently
Local Translation Caching
In-Memory Cache for ARB Files
Flutter's built-in localization already caches loaded ARB files in memory. But you can optimize further:
class TranslationCache {
static final TranslationCache _instance = TranslationCache._internal();
factory TranslationCache() => _instance;
TranslationCache._internal();
final Map<String, Map<String, String>> _cache = {};
final Map<String, DateTime> _loadTimes = {};
static const Duration _maxAge = Duration(hours: 24);
bool hasValidCache(String locale) {
if (!_cache.containsKey(locale)) return false;
final loadTime = _loadTimes[locale];
if (loadTime == null) return false;
return DateTime.now().difference(loadTime) < _maxAge;
}
Map<String, String>? get(String locale) {
if (hasValidCache(locale)) {
return _cache[locale];
}
return null;
}
void set(String locale, Map<String, String> translations) {
_cache[locale] = translations;
_loadTimes[locale] = DateTime.now();
}
void clear([String? locale]) {
if (locale != null) {
_cache.remove(locale);
_loadTimes.remove(locale);
} else {
_cache.clear();
_loadTimes.clear();
}
}
void clearExpired() {
final now = DateTime.now();
final expiredLocales = _loadTimes.entries
.where((e) => now.difference(e.value) >= _maxAge)
.map((e) => e.key)
.toList();
for (final locale in expiredLocales) {
clear(locale);
}
}
}
Persistent Cache with SharedPreferences
For faster cold starts, cache translations in SharedPreferences:
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class PersistentTranslationCache {
static const String _cachePrefix = 'translations_';
static const String _timestampPrefix = 'translations_ts_';
static const Duration _maxAge = Duration(days: 7);
final SharedPreferences _prefs;
PersistentTranslationCache(this._prefs);
static Future<PersistentTranslationCache> create() async {
final prefs = await SharedPreferences.getInstance();
return PersistentTranslationCache(prefs);
}
Future<Map<String, String>?> get(String locale) async {
final timestamp = _prefs.getInt('$_timestampPrefix$locale');
if (timestamp == null) return null;
final age = DateTime.now().millisecondsSinceEpoch - timestamp;
if (age > _maxAge.inMilliseconds) {
await clear(locale);
return null;
}
final jsonString = _prefs.getString('$_cachePrefix$locale');
if (jsonString == null) return null;
try {
final Map<String, dynamic> json = jsonDecode(jsonString);
return json.map((key, value) => MapEntry(key, value.toString()));
} catch (e) {
await clear(locale);
return null;
}
}
Future<void> set(String locale, Map<String, String> translations) async {
final jsonString = jsonEncode(translations);
await _prefs.setString('$_cachePrefix$locale', jsonString);
await _prefs.setInt(
'$_timestampPrefix$locale',
DateTime.now().millisecondsSinceEpoch,
);
}
Future<void> clear([String? locale]) async {
if (locale != null) {
await _prefs.remove('$_cachePrefix$locale');
await _prefs.remove('$_timestampPrefix$locale');
} else {
final keys = _prefs.getKeys();
for (final key in keys) {
if (key.startsWith(_cachePrefix) || key.startsWith(_timestampPrefix)) {
await _prefs.remove(key);
}
}
}
}
}
File-Based Cache with Hive
For larger translation files, use Hive for better performance:
import 'package:hive_flutter/hive_flutter.dart';
class HiveTranslationCache {
static const String _boxName = 'translations';
late Box<Map> _box;
Future<void> init() async {
await Hive.initFlutter();
_box = await Hive.openBox<Map>(_boxName);
}
Map<String, String>? get(String locale) {
final data = _box.get(locale);
if (data == null) return null;
final timestamp = data['_timestamp'] as int?;
if (timestamp == null) return null;
final age = DateTime.now().millisecondsSinceEpoch - timestamp;
if (age > const Duration(days: 7).inMilliseconds) {
_box.delete(locale);
return null;
}
final translations = Map<String, String>.from(
data.map((key, value) {
if (key == '_timestamp') return MapEntry(key, '');
return MapEntry(key.toString(), value.toString());
}),
);
translations.remove('_timestamp');
return translations;
}
Future<void> set(String locale, Map<String, String> translations) async {
final data = Map<String, dynamic>.from(translations);
data['_timestamp'] = DateTime.now().millisecondsSinceEpoch;
await _box.put(locale, data);
}
Future<void> clear([String? locale]) async {
if (locale != null) {
await _box.delete(locale);
} else {
await _box.clear();
}
}
Future<void> close() async {
await _box.close();
}
}
Remote Translation Caching
Cache-First Strategy
Load from cache first, then update from network:
class RemoteTranslationService {
final HiveTranslationCache _cache;
final http.Client _client;
final String _baseUrl;
RemoteTranslationService({
required HiveTranslationCache cache,
required String baseUrl,
http.Client? client,
}) : _cache = cache,
_baseUrl = baseUrl,
_client = client ?? http.Client();
Future<Map<String, String>> getTranslations(String locale) async {
// 1. Try cache first
final cached = _cache.get(locale);
if (cached != null) {
// Return cached immediately
_refreshInBackground(locale);
return cached;
}
// 2. Fetch from network
return _fetchAndCache(locale);
}
Future<Map<String, String>> _fetchAndCache(String locale) async {
try {
final response = await _client.get(
Uri.parse('$_baseUrl/translations/$locale.json'),
);
if (response.statusCode == 200) {
final Map<String, dynamic> json = jsonDecode(response.body);
final translations = json.map(
(key, value) => MapEntry(key, value.toString()),
);
await _cache.set(locale, translations);
return translations;
}
throw Exception('Failed to load translations: ${response.statusCode}');
} catch (e) {
// Return empty map or throw based on your error handling strategy
final cached = _cache.get(locale);
if (cached != null) return cached;
rethrow;
}
}
void _refreshInBackground(String locale) {
// Fire and forget - update cache in background
_fetchAndCache(locale).catchError((e) {
// Log error but don't interrupt user
debugPrint('Background translation refresh failed: $e');
});
}
}
Stale-While-Revalidate Pattern
Return stale data immediately while revalidating:
class SWRTranslationService {
final PersistentTranslationCache _cache;
final http.Client _client;
final String _baseUrl;
// Track ongoing requests to prevent duplicates
final Map<String, Future<Map<String, String>>> _pendingRequests = {};
SWRTranslationService({
required PersistentTranslationCache cache,
required String baseUrl,
http.Client? client,
}) : _cache = cache,
_baseUrl = baseUrl,
_client = client ?? http.Client();
Stream<Map<String, String>> getTranslations(String locale) async* {
// 1. Yield cached data immediately if available
final cached = await _cache.get(locale);
if (cached != null) {
yield cached;
}
// 2. Fetch fresh data
try {
final fresh = await _fetchTranslations(locale);
if (fresh != cached) {
yield fresh;
}
} catch (e) {
// If we already yielded cached data, swallow the error
if (cached == null) {
rethrow;
}
}
}
Future<Map<String, String>> _fetchTranslations(String locale) {
// Deduplicate concurrent requests
if (_pendingRequests.containsKey(locale)) {
return _pendingRequests[locale]!;
}
final future = _doFetch(locale).whenComplete(() {
_pendingRequests.remove(locale);
});
_pendingRequests[locale] = future;
return future;
}
Future<Map<String, String>> _doFetch(String locale) async {
final response = await _client.get(
Uri.parse('$_baseUrl/translations/$locale.json'),
);
if (response.statusCode != 200) {
throw Exception('HTTP ${response.statusCode}');
}
final Map<String, dynamic> json = jsonDecode(response.body);
final translations = json.map(
(key, value) => MapEntry(key, value.toString()),
);
await _cache.set(locale, translations);
return translations;
}
}
// Usage with StreamBuilder:
StreamBuilder<Map<String, String>>(
stream: translationService.getTranslations('es'),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Text(snapshot.data!['greeting'] ?? 'Hello');
}
if (snapshot.hasError) {
return Text('Error loading translations');
}
return CircularProgressIndicator();
},
)
ETag-Based Cache Validation
Use ETags to avoid downloading unchanged translations:
class ETagTranslationService {
final SharedPreferences _prefs;
final http.Client _client;
final String _baseUrl;
static const String _etagPrefix = 'etag_';
static const String _dataPrefix = 'data_';
ETagTranslationService({
required SharedPreferences prefs,
required String baseUrl,
http.Client? client,
}) : _prefs = prefs,
_baseUrl = baseUrl,
_client = client ?? http.Client();
Future<Map<String, String>> getTranslations(String locale) async {
final etag = _prefs.getString('$_etagPrefix$locale');
final cachedData = _prefs.getString('$_dataPrefix$locale');
final headers = <String, String>{};
if (etag != null) {
headers['If-None-Match'] = etag;
}
try {
final response = await _client.get(
Uri.parse('$_baseUrl/translations/$locale.json'),
headers: headers,
);
if (response.statusCode == 304) {
// Not modified - use cached data
if (cachedData != null) {
final json = jsonDecode(cachedData) as Map<String, dynamic>;
return json.map((k, v) => MapEntry(k, v.toString()));
}
}
if (response.statusCode == 200) {
// Save new ETag and data
final newEtag = response.headers['etag'];
if (newEtag != null) {
await _prefs.setString('$_etagPrefix$locale', newEtag);
}
await _prefs.setString('$_dataPrefix$locale', response.body);
final json = jsonDecode(response.body) as Map<String, dynamic>;
return json.map((k, v) => MapEntry(k, v.toString()));
}
throw Exception('HTTP ${response.statusCode}');
} catch (e) {
// Fallback to cache on network error
if (cachedData != null) {
final json = jsonDecode(cachedData) as Map<String, dynamic>;
return json.map((k, v) => MapEntry(k, v.toString()));
}
rethrow;
}
}
}
LRU Cache for Multiple Locales
Limit memory usage with an LRU (Least Recently Used) cache:
class LRUTranslationCache {
final int maxSize;
final LinkedHashMap<String, Map<String, String>> _cache = LinkedHashMap();
LRUTranslationCache({this.maxSize = 5});
Map<String, String>? get(String locale) {
final value = _cache.remove(locale);
if (value != null) {
// Move to end (most recently used)
_cache[locale] = value;
}
return value;
}
void set(String locale, Map<String, String> translations) {
// Remove if exists to update position
_cache.remove(locale);
// Evict oldest if at capacity
while (_cache.length >= maxSize) {
_cache.remove(_cache.keys.first);
}
_cache[locale] = translations;
}
void clear() {
_cache.clear();
}
int get length => _cache.length;
bool get isEmpty => _cache.isEmpty;
}
Preloading Translations
Preload translations during app startup or splash screen:
class TranslationPreloader {
final RemoteTranslationService _service;
final List<String> _priorityLocales;
TranslationPreloader({
required RemoteTranslationService service,
required List<String> priorityLocales,
}) : _service = service,
_priorityLocales = priorityLocales;
Future<void> preloadAll() async {
await Future.wait(
_priorityLocales.map((locale) => _preloadLocale(locale)),
);
}
Future<void> _preloadLocale(String locale) async {
try {
await _service.getTranslations(locale);
debugPrint('Preloaded translations for $locale');
} catch (e) {
debugPrint('Failed to preload $locale: $e');
}
}
/// Preload with progress callback
Stream<double> preloadWithProgress() async* {
for (var i = 0; i < _priorityLocales.length; i++) {
await _preloadLocale(_priorityLocales[i]);
yield (i + 1) / _priorityLocales.length;
}
}
}
// Usage in splash screen:
class SplashScreen extends StatefulWidget {
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
double _progress = 0;
@override
void initState() {
super.initState();
_preloadTranslations();
}
void _preloadTranslations() async {
final preloader = TranslationPreloader(
service: context.read<RemoteTranslationService>(),
priorityLocales: ['en', 'es', 'fr'], // Most common locales
);
await for (final progress in preloader.preloadWithProgress()) {
setState(() => _progress = progress);
}
// Navigate to home
Navigator.pushReplacementNamed(context, '/home');
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const FlutterLogo(size: 100),
const SizedBox(height: 24),
LinearProgressIndicator(value: _progress),
const SizedBox(height: 8),
Text('Loading translations... ${(_progress * 100).toInt()}%'),
],
),
),
);
}
}
Cache Invalidation Strategies
Version-Based Invalidation
class VersionedTranslationCache {
final SharedPreferences _prefs;
static const String _versionKey = 'translations_version';
VersionedTranslationCache(this._prefs);
Future<void> invalidateIfVersionChanged(String newVersion) async {
final currentVersion = _prefs.getString(_versionKey);
if (currentVersion != newVersion) {
// Clear all cached translations
final keys = _prefs.getKeys().where(
(k) => k.startsWith('translations_'),
);
for (final key in keys) {
await _prefs.remove(key);
}
// Save new version
await _prefs.setString(_versionKey, newVersion);
}
}
}
// Usage at app startup:
final version = await fetchTranslationVersion(); // From your API
await cache.invalidateIfVersionChanged(version);
Time-Based Invalidation
class TimeBasedCache {
Future<Map<String, String>?> get(
String locale, {
Duration maxAge = const Duration(hours: 24),
}) async {
final prefs = await SharedPreferences.getInstance();
final timestamp = prefs.getInt('ts_$locale');
if (timestamp == null) return null;
final age = DateTime.now().millisecondsSinceEpoch - timestamp;
if (age > maxAge.inMilliseconds) {
await prefs.remove('data_$locale');
await prefs.remove('ts_$locale');
return null;
}
final data = prefs.getString('data_$locale');
if (data == null) return null;
return Map<String, String>.from(jsonDecode(data));
}
}
Complete Caching Solution
Here's a production-ready caching solution combining all strategies:
class TranslationCacheManager {
final LRUTranslationCache _memoryCache;
final HiveTranslationCache _diskCache;
final http.Client _client;
final String _baseUrl;
TranslationCacheManager({
required String baseUrl,
int memoryCacheSize = 5,
http.Client? client,
}) : _memoryCache = LRUTranslationCache(maxSize: memoryCacheSize),
_diskCache = HiveTranslationCache(),
_baseUrl = baseUrl,
_client = client ?? http.Client();
Future<void> init() async {
await _diskCache.init();
}
Future<Map<String, String>> getTranslations(String locale) async {
// 1. Check memory cache (fastest)
var translations = _memoryCache.get(locale);
if (translations != null) {
return translations;
}
// 2. Check disk cache
translations = _diskCache.get(locale);
if (translations != null) {
_memoryCache.set(locale, translations);
_refreshInBackground(locale);
return translations;
}
// 3. Fetch from network
return _fetchAndCache(locale);
}
Future<Map<String, String>> _fetchAndCache(String locale) async {
final response = await _client.get(
Uri.parse('$_baseUrl/translations/$locale.json'),
);
if (response.statusCode != 200) {
throw Exception('HTTP ${response.statusCode}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final translations = json.map((k, v) => MapEntry(k, v.toString()));
// Update both caches
_memoryCache.set(locale, translations);
await _diskCache.set(locale, translations);
return translations;
}
void _refreshInBackground(String locale) {
_fetchAndCache(locale).catchError((e) {
debugPrint('Background refresh failed for $locale: $e');
});
}
Future<void> clearAll() async {
_memoryCache.clear();
await _diskCache.clear();
}
Future<void> close() async {
await _diskCache.close();
}
}
Best Practices
- Layer your caches: Memory → Disk → Network
- Set appropriate TTLs: Balance freshness vs performance
- Handle errors gracefully: Always fall back to cached data
- Preload common locales: Improve perceived performance
- Use ETags: Minimize bandwidth when translations haven't changed
- Limit memory usage: Use LRU eviction for memory caches
- Background refresh: Update cache without blocking UI
Conclusion
Effective caching makes your multilingual app feel fast and responsive. Start with simple in-memory caching, then add disk persistence and network optimization as needed.
Need help managing translations across multiple locales? FlutterLocalisation provides a complete solution for managing ARB files with team collaboration and versioning built in.