← Back to Blog

Flutter Offline-First Localization: Build Apps That Work Without Internet

flutterlocalizationofflinecachinghivesync

Flutter Offline-First Localization: Build Apps That Work Without Internet

Mobile apps need to work reliably regardless of network conditions. This is especially true for localization - users expect their app to display in their preferred language even when offline. This guide covers strategies for building offline-first localization in Flutter, from bundled translations to smart caching and sync mechanisms.

Why Offline-First Localization Matters

Consider these scenarios:

  • Travel apps used in areas with poor connectivity
  • Field service apps used in remote locations
  • Enterprise apps in secure facilities with limited internet
  • Emerging markets with unreliable mobile data
  • Subway commuters passing through tunnels

If your app requires network access just to display text, you'll frustrate users and lose engagement.

Architecture Overview

An offline-first localization system needs:

  1. Bundled translations: Default translations that ship with the app
  2. Local storage: Persistent cache for downloaded translations
  3. Sync mechanism: Background updates when online
  4. Fallback chain: Graceful degradation when translations are missing
  5. Version management: Track translation versions for efficient updates

Project Setup

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  hive: ^2.2.3
  hive_flutter: ^1.1.0
  connectivity_plus: ^5.0.2
  path_provider: ^2.1.2

dev_dependencies:
  hive_generator: ^2.0.1
  build_runner: ^2.4.8

flutter:
  generate: true
  assets:
    - assets/translations/

Bundled Translations (The Foundation)

Always ship default translations with your app:

// lib/l10n/bundled_translations.dart
class BundledTranslations {
  static const Map<String, Map<String, String>> translations = {
    'en': {
      'app_name': 'My App',
      'welcome': 'Welcome',
      'settings': 'Settings',
      'language': 'Language',
      'offline_mode': 'Offline Mode',
      'last_updated': 'Last updated: {date}',
      'error_loading': 'Error loading content',
      'retry': 'Retry',
      // Add all essential translations
    },
    'es': {
      'app_name': 'Mi App',
      'welcome': 'Bienvenido',
      'settings': 'Configuracion',
      'language': 'Idioma',
      'offline_mode': 'Modo sin conexion',
      'last_updated': 'Ultima actualizacion: {date}',
      'error_loading': 'Error al cargar contenido',
      'retry': 'Reintentar',
    },
    'fr': {
      'app_name': 'Mon App',
      'welcome': 'Bienvenue',
      'settings': 'Parametres',
      'language': 'Langue',
      'offline_mode': 'Mode hors ligne',
      'last_updated': 'Derniere mise a jour: {date}',
      'error_loading': 'Erreur de chargement',
      'retry': 'Reessayer',
    },
  };

  static Map<String, String> getTranslations(String languageCode) {
    return translations[languageCode] ?? translations['en']!;
  }

  static bool hasLanguage(String languageCode) {
    return translations.containsKey(languageCode);
  }

  static List<String> get supportedLanguages => translations.keys.toList();
}

Local Storage with Hive

Create a persistent cache for downloaded translations:

// lib/l10n/translation_cache.dart
import 'package:hive_flutter/hive_flutter.dart';

part 'translation_cache.g.dart';

@HiveType(typeId: 0)
class CachedTranslation extends HiveObject {
  @HiveField(0)
  final String languageCode;

  @HiveField(1)
  final Map<String, String> translations;

  @HiveField(2)
  final int version;

  @HiveField(3)
  final DateTime cachedAt;

  @HiveField(4)
  final DateTime? expiresAt;

  CachedTranslation({
    required this.languageCode,
    required this.translations,
    required this.version,
    required this.cachedAt,
    this.expiresAt,
  });

  bool get isExpired {
    if (expiresAt == null) return false;
    return DateTime.now().isAfter(expiresAt!);
  }

  bool get isStale {
    // Consider translations stale after 24 hours
    return DateTime.now().difference(cachedAt).inHours > 24;
  }
}

class TranslationCacheService {
  static const String _boxName = 'translations';
  late Box<CachedTranslation> _box;

  Future<void> init() async {
    await Hive.initFlutter();
    Hive.registerAdapter(CachedTranslationAdapter());
    _box = await Hive.openBox<CachedTranslation>(_boxName);
  }

  Future<void> cacheTranslations({
    required String languageCode,
    required Map<String, String> translations,
    required int version,
    Duration? ttl,
  }) async {
    final cached = CachedTranslation(
      languageCode: languageCode,
      translations: translations,
      version: version,
      cachedAt: DateTime.now(),
      expiresAt: ttl != null ? DateTime.now().add(ttl) : null,
    );

    await _box.put(languageCode, cached);
  }

  CachedTranslation? getTranslations(String languageCode) {
    return _box.get(languageCode);
  }

  bool hasTranslations(String languageCode) {
    final cached = _box.get(languageCode);
    return cached != null && !cached.isExpired;
  }

  int? getVersion(String languageCode) {
    return _box.get(languageCode)?.version;
  }

  Future<void> clearCache() async {
    await _box.clear();
  }

  Future<void> removeLanguage(String languageCode) async {
    await _box.delete(languageCode);
  }

  List<String> getCachedLanguages() {
    return _box.keys.cast<String>().toList();
  }
}

Run code generation:

flutter pub run build_runner build

Translation Sync Service

Handle background synchronization:

// lib/l10n/translation_sync_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:connectivity_plus/connectivity_plus.dart';
import 'translation_cache.dart';
import 'bundled_translations.dart';

class TranslationSyncService {
  final TranslationCacheService _cache;
  final String _apiBaseUrl;
  final Connectivity _connectivity = Connectivity();

  TranslationSyncService({
    required TranslationCacheService cache,
    required String apiBaseUrl,
  })  : _cache = cache,
        _apiBaseUrl = apiBaseUrl;

  /// Check if device is online
  Future<bool> get isOnline async {
    final result = await _connectivity.checkConnectivity();
    return result != ConnectivityResult.none;
  }

  /// Get translations with offline-first strategy
  Future<Map<String, String>> getTranslations(String languageCode) async {
    // 1. Try to get from cache first
    final cached = _cache.getTranslations(languageCode);

    if (cached != null && !cached.isExpired) {
      // If cache is stale but not expired, trigger background refresh
      if (cached.isStale) {
        _refreshInBackground(languageCode, cached.version);
      }
      return cached.translations;
    }

    // 2. Try to fetch from network
    if (await isOnline) {
      try {
        final remote = await _fetchFromApi(languageCode);
        if (remote != null) {
          return remote;
        }
      } catch (e) {
        print('Failed to fetch translations: $e');
      }
    }

    // 3. Fall back to expired cache if available
    if (cached != null) {
      return cached.translations;
    }

    // 4. Fall back to bundled translations
    return BundledTranslations.getTranslations(languageCode);
  }

  /// Fetch translations from API
  Future<Map<String, String>?> _fetchFromApi(String languageCode) async {
    final currentVersion = _cache.getVersion(languageCode);

    final response = await http.get(
      Uri.parse('$_apiBaseUrl/translations/$languageCode'),
      headers: {
        if (currentVersion != null) 'If-None-Match': currentVersion.toString(),
      },
    );

    if (response.statusCode == 304) {
      // Not modified, cache is still valid
      return null;
    }

    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      final translations = Map<String, String>.from(data['translations']);
      final version = data['version'] as int;

      // Cache the new translations
      await _cache.cacheTranslations(
        languageCode: languageCode,
        translations: translations,
        version: version,
        ttl: const Duration(days: 7),
      );

      return translations;
    }

    return null;
  }

  /// Background refresh without blocking UI
  void _refreshInBackground(String languageCode, int currentVersion) async {
    if (!await isOnline) return;

    try {
      await _fetchFromApi(languageCode);
    } catch (e) {
      // Silently fail - we have cached data
      print('Background refresh failed: $e');
    }
  }

  /// Pre-download translations for offline use
  Future<void> preloadLanguages(List<String> languageCodes) async {
    if (!await isOnline) return;

    for (final code in languageCodes) {
      try {
        await _fetchFromApi(code);
      } catch (e) {
        print('Failed to preload $code: $e');
      }
    }
  }

  /// Force refresh all cached translations
  Future<void> refreshAll() async {
    if (!await isOnline) return;

    final cachedLanguages = _cache.getCachedLanguages();
    for (final code in cachedLanguages) {
      try {
        await _fetchFromApi(code);
      } catch (e) {
        print('Failed to refresh $code: $e');
      }
    }
  }

  /// Listen to connectivity changes
  Stream<ConnectivityResult> get connectivityStream =>
      _connectivity.onConnectivityChanged;
}

Offline-First Localization Provider

Create a provider that manages the entire localization state:

// lib/l10n/offline_localization_provider.dart
import 'package:flutter/material.dart';
import 'translation_cache.dart';
import 'translation_sync_service.dart';
import 'bundled_translations.dart';

class OfflineLocalizationProvider extends ChangeNotifier {
  final TranslationCacheService _cache;
  final TranslationSyncService _syncService;

  Locale _locale = const Locale('en');
  Map<String, String> _translations = {};
  bool _isLoading = false;
  bool _isOffline = false;
  DateTime? _lastUpdated;

  OfflineLocalizationProvider({
    required TranslationCacheService cache,
    required TranslationSyncService syncService,
  })  : _cache = cache,
        _syncService = syncService {
    _listenToConnectivity();
  }

  Locale get locale => _locale;
  bool get isLoading => _isLoading;
  bool get isOffline => _isOffline;
  DateTime? get lastUpdated => _lastUpdated;

  String translate(String key, {Map<String, String>? params}) {
    var text = _translations[key] ?? key;

    if (params != null) {
      params.forEach((paramKey, value) {
        text = text.replaceAll('{$paramKey}', value);
      });
    }

    return text;
  }

  Future<void> init() async {
    await _cache.init();
    await loadTranslations(_locale.languageCode);
  }

  Future<void> setLocale(Locale newLocale) async {
    if (newLocale == _locale) return;

    _locale = newLocale;
    await loadTranslations(newLocale.languageCode);
    notifyListeners();
  }

  Future<void> loadTranslations(String languageCode) async {
    _isLoading = true;
    notifyListeners();

    try {
      _translations = await _syncService.getTranslations(languageCode);

      final cached = _cache.getTranslations(languageCode);
      _lastUpdated = cached?.cachedAt;
    } catch (e) {
      // Fall back to bundled
      _translations = BundledTranslations.getTranslations(languageCode);
      _lastUpdated = null;
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  Future<void> refresh() async {
    await loadTranslations(_locale.languageCode);
  }

  Future<void> preloadLanguages(List<String> languageCodes) async {
    await _syncService.preloadLanguages(languageCodes);
  }

  void _listenToConnectivity() {
    _syncService.connectivityStream.listen((result) {
      final wasOffline = _isOffline;
      _isOffline = result == ConnectivityResult.none;

      if (wasOffline && !_isOffline) {
        // Just came online - refresh translations
        refresh();
      }

      notifyListeners();
    });
  }
}

Using the Provider in Your App

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'l10n/offline_localization_provider.dart';
import 'l10n/translation_cache.dart';
import 'l10n/translation_sync_service.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final cache = TranslationCacheService();
  await cache.init();

  final syncService = TranslationSyncService(
    cache: cache,
    apiBaseUrl: 'https://api.example.com',
  );

  final localizationProvider = OfflineLocalizationProvider(
    cache: cache,
    syncService: syncService,
  );
  await localizationProvider.init();

  runApp(
    ChangeNotifierProvider.value(
      value: localizationProvider,
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return Consumer<OfflineLocalizationProvider>(
      builder: (context, l10n, child) {
        return MaterialApp(
          locale: l10n.locale,
          home: const HomePage(),
        );
      },
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = context.watch<OfflineLocalizationProvider>();

    return Scaffold(
      appBar: AppBar(
        title: Text(l10n.translate('app_name')),
        actions: [
          if (l10n.isOffline)
            const Padding(
              padding: EdgeInsets.all(8.0),
              child: Icon(Icons.cloud_off, color: Colors.orange),
            ),
        ],
      ),
      body: Column(
        children: [
          if (l10n.isOffline)
            Container(
              color: Colors.orange.shade100,
              padding: const EdgeInsets.all(8),
              child: Row(
                children: [
                  const Icon(Icons.info_outline),
                  const SizedBox(width: 8),
                  Expanded(
                    child: Text(l10n.translate('offline_mode')),
                  ),
                ],
              ),
            ),
          Expanded(
            child: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text(
                    l10n.translate('welcome'),
                    style: Theme.of(context).textTheme.headlineMedium,
                  ),
                  if (l10n.lastUpdated != null)
                    Text(
                      l10n.translate(
                        'last_updated',
                        params: {'date': l10n.lastUpdated!.toString()},
                      ),
                      style: Theme.of(context).textTheme.bodySmall,
                    ),
                ],
              ),
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: l10n.refresh,
        child: l10n.isLoading
            ? const CircularProgressIndicator(color: Colors.white)
            : const Icon(Icons.refresh),
      ),
    );
  }
}

Extension Method for Clean Access

// lib/l10n/extensions.dart
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import 'offline_localization_provider.dart';

extension LocalizationExtension on BuildContext {
  OfflineLocalizationProvider get l10n =>
      read<OfflineLocalizationProvider>();

  String tr(String key, {Map<String, String>? params}) =>
      read<OfflineLocalizationProvider>().translate(key, params: params);
}

Usage becomes simple:

Text(context.tr('welcome'));
Text(context.tr('last_updated', params: {'date': '2025-01-01'}));

Handling Large Translation Files

For apps with thousands of strings, implement chunked loading:

// lib/l10n/chunked_translation_service.dart
class ChunkedTranslationService {
  final TranslationCacheService _cache;

  ChunkedTranslationService(this._cache);

  /// Load translations by module/feature
  Future<Map<String, String>> loadModule({
    required String languageCode,
    required String module,
  }) async {
    final cacheKey = '${languageCode}_$module';
    final cached = _cache.getTranslations(cacheKey);

    if (cached != null && !cached.isExpired) {
      return cached.translations;
    }

    // Fetch module-specific translations
    // ...

    return {};
  }

  /// Preload critical modules for offline use
  Future<void> preloadCriticalModules(String languageCode) async {
    const criticalModules = ['common', 'auth', 'errors'];

    for (final module in criticalModules) {
      await loadModule(languageCode: languageCode, module: module);
    }
  }
}

Delta Updates for Efficiency

Only download changed translations:

// lib/l10n/delta_sync_service.dart
class DeltaSyncService {
  Future<Map<String, String>?> fetchDelta({
    required String languageCode,
    required int fromVersion,
  }) async {
    final response = await http.get(
      Uri.parse('$_apiBaseUrl/translations/$languageCode/delta'),
      headers: {
        'X-From-Version': fromVersion.toString(),
      },
    );

    if (response.statusCode == 200) {
      final data = json.decode(response.body);

      return {
        'added': data['added'],
        'modified': data['modified'],
        'removed': data['removed'],
        'version': data['version'],
      };
    }

    return null;
  }

  Future<void> applyDelta({
    required String languageCode,
    required Map<String, dynamic> delta,
    required Map<String, String> currentTranslations,
  }) async {
    final updated = Map<String, String>.from(currentTranslations);

    // Apply changes
    if (delta['added'] != null) {
      updated.addAll(Map<String, String>.from(delta['added']));
    }

    if (delta['modified'] != null) {
      updated.addAll(Map<String, String>.from(delta['modified']));
    }

    if (delta['removed'] != null) {
      for (final key in delta['removed'] as List) {
        updated.remove(key);
      }
    }

    // Save to cache with new version
    await _cache.cacheTranslations(
      languageCode: languageCode,
      translations: updated,
      version: delta['version'] as int,
    );
  }
}

Background Sync with WorkManager

For Android and iOS background sync:

# pubspec.yaml
dependencies:
  workmanager: ^0.5.2
// lib/l10n/background_sync.dart
import 'package:workmanager/workmanager.dart';

const translationSyncTask = 'translationSyncTask';

@pragma('vm:entry-point')
void callbackDispatcher() {
  Workmanager().executeTask((task, inputData) async {
    if (task == translationSyncTask) {
      // Initialize services
      final cache = TranslationCacheService();
      await cache.init();

      final syncService = TranslationSyncService(
        cache: cache,
        apiBaseUrl: 'https://api.example.com',
      );

      // Sync all cached languages
      await syncService.refreshAll();

      return true;
    }
    return false;
  });
}

class BackgroundSyncManager {
  static Future<void> init() async {
    await Workmanager().initialize(callbackDispatcher);
  }

  static Future<void> schedulePeriodicSync() async {
    await Workmanager().registerPeriodicTask(
      'translation-sync',
      translationSyncTask,
      frequency: const Duration(hours: 12),
      constraints: Constraints(
        networkType: NetworkType.connected,
        requiresBatteryNotLow: true,
      ),
    );
  }

  static Future<void> triggerImmediateSync() async {
    await Workmanager().registerOneOffTask(
      'translation-sync-now',
      translationSyncTask,
      constraints: Constraints(
        networkType: NetworkType.connected,
      ),
    );
  }
}

Testing Offline Scenarios

// test/offline_localization_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';

class MockTranslationCacheService extends Mock
    implements TranslationCacheService {}

class MockTranslationSyncService extends Mock
    implements TranslationSyncService {}

void main() {
  group('Offline Localization', () {
    late MockTranslationCacheService mockCache;
    late MockTranslationSyncService mockSync;
    late OfflineLocalizationProvider provider;

    setUp(() {
      mockCache = MockTranslationCacheService();
      mockSync = MockTranslationSyncService();
      provider = OfflineLocalizationProvider(
        cache: mockCache,
        syncService: mockSync,
      );
    });

    test('should use cached translations when offline', () async {
      // Arrange
      when(mockSync.isOnline).thenAnswer((_) async => false);
      when(mockSync.getTranslations('en')).thenAnswer(
        (_) async => {'welcome': 'Cached Welcome'},
      );

      // Act
      await provider.loadTranslations('en');

      // Assert
      expect(provider.translate('welcome'), 'Cached Welcome');
    });

    test('should fall back to bundled when cache is empty', () async {
      // Arrange
      when(mockSync.isOnline).thenAnswer((_) async => false);
      when(mockSync.getTranslations('en')).thenThrow(Exception('No cache'));

      // Act
      await provider.loadTranslations('en');

      // Assert
      expect(
        provider.translate('welcome'),
        BundledTranslations.translations['en']!['welcome'],
      );
    });

    test('should refresh when coming back online', () async {
      // Test connectivity change triggers refresh
    });
  });
}

Best Practices

1. Always Bundle Core Translations

Never rely solely on remote translations:

// Essential strings that must always work
const coreStrings = [
  'app_name',
  'error_generic',
  'retry',
  'cancel',
  'ok',
  'loading',
  'offline_mode',
];

2. Implement Graceful Degradation

String translate(String key) {
  // Try cached translations
  var text = _cachedTranslations[key];

  // Fall back to bundled
  text ??= BundledTranslations.get(_locale, key);

  // Fall back to English bundled
  text ??= BundledTranslations.get('en', key);

  // Last resort: return the key itself
  return text ?? key;
}

3. Show Sync Status to Users

Widget buildSyncIndicator(OfflineLocalizationProvider l10n) {
  if (l10n.isLoading) {
    return const CircularProgressIndicator();
  }

  if (l10n.isOffline) {
    return const Icon(Icons.cloud_off, color: Colors.orange);
  }

  if (l10n.lastUpdated != null) {
    final age = DateTime.now().difference(l10n.lastUpdated!);
    if (age.inDays > 7) {
      return const Icon(Icons.warning, color: Colors.yellow);
    }
  }

  return const Icon(Icons.cloud_done, color: Colors.green);
}

4. Compress Translation Data

// Use gzip compression for API responses
Future<Map<String, String>> fetchCompressed(String languageCode) async {
  final response = await http.get(
    Uri.parse('$_apiBaseUrl/translations/$languageCode'),
    headers: {'Accept-Encoding': 'gzip'},
  );

  // Response is automatically decompressed by http package
  return json.decode(response.body);
}

Conclusion

Building offline-first localization in Flutter requires:

  • Bundled translations as an always-available fallback
  • Persistent local storage with Hive for cached translations
  • Smart sync mechanisms that respect network conditions
  • Delta updates for bandwidth efficiency
  • Background sync to keep translations fresh
  • Clear user feedback about sync status

With these patterns, your app will work reliably in any network condition while still benefiting from remote translation updates.

For managing your translation files and deploying updates, FlutterLocalisation provides API endpoints, version management, and delta sync support that integrates with this offline-first architecture.