← Back to Blog

Flutter OTA Localization: Over-the-Air Translation Updates Without App Store Releases

flutterotaover-the-airlocalizationremoteupdatescaching

Flutter OTA Localization: Over-the-Air Translation Updates Without App Store Releases

Updating translations shouldn't require a full app release. Over-the-air (OTA) localization lets you push new translations, fix typos, and add languages instantly. This guide shows you how to implement OTA translation updates in Flutter with proper caching, versioning, and fallback strategies.

Why OTA Localization?

Traditional localization requires app store releases for every change:

  • Slow deployment - App review can take days
  • User friction - Users must update the app
  • High cost - Each release has overhead
  • Inflexible - Can't respond quickly to issues

OTA localization solves these problems:

  • Instant updates - Changes go live immediately
  • No user action - Translations update silently
  • A/B testing - Test different translations
  • Quick fixes - Correct typos without releases
  • New languages - Add locales on demand

Architecture Overview

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   Translation   │────▶│   CDN/Server    │────▶│   Flutter App   │
│   Management    │     │   (Versioned)   │     │   (Cached)      │
└─────────────────┘     └─────────────────┘     └─────────────────┘
        │                       │                       │
        ▼                       ▼                       ▼
   Upload new             Version check           Load & cache
   translations           + download              translations

Setting Up OTA Localization

Translation Model

class OTATranslation {
  final String locale;
  final int version;
  final DateTime updatedAt;
  final Map<String, String> translations;
  final Map<String, dynamic>? metadata;

  OTATranslation({
    required this.locale,
    required this.version,
    required this.updatedAt,
    required this.translations,
    this.metadata,
  });

  factory OTATranslation.fromJson(Map<String, dynamic> json) {
    return OTATranslation(
      locale: json['locale'] as String,
      version: json['version'] as int,
      updatedAt: DateTime.parse(json['updatedAt'] as String),
      translations: Map<String, String>.from(json['translations'] as Map),
      metadata: json['metadata'] as Map<String, dynamic>?,
    );
  }

  Map<String, dynamic> toJson() => {
    'locale': locale,
    'version': version,
    'updatedAt': updatedAt.toIso8601String(),
    'translations': translations,
    'metadata': metadata,
  };

  String? get(String key) => translations[key];

  String getOrDefault(String key, String defaultValue) {
    return translations[key] ?? defaultValue;
  }
}

OTA Translation Service

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';

class OTALocalizationService {
  final String baseUrl;
  final Duration checkInterval;
  final Map<String, OTATranslation> _cache = {};
  final Map<String, OTATranslation> _bundledTranslations;

  Timer? _updateTimer;
  bool _isInitialized = false;

  OTALocalizationService({
    required this.baseUrl,
    required Map<String, OTATranslation> bundledTranslations,
    this.checkInterval = const Duration(hours: 1),
  }) : _bundledTranslations = bundledTranslations;

  Future<void> initialize() async {
    if (_isInitialized) return;

    // Load cached translations first
    await _loadCachedTranslations();

    // Start background update checks
    _startUpdateTimer();

    // Check for updates now (non-blocking)
    _checkForUpdates();

    _isInitialized = true;
  }

  Future<void> _loadCachedTranslations() async {
    try {
      final directory = await getApplicationDocumentsDirectory();
      final cacheDir = Directory('${directory.path}/translations');

      if (!await cacheDir.exists()) {
        // Use bundled translations
        _cache.addAll(_bundledTranslations);
        return;
      }

      // Load each cached locale
      await for (final file in cacheDir.list()) {
        if (file is File && file.path.endsWith('.json')) {
          try {
            final content = await file.readAsString();
            final json = jsonDecode(content) as Map<String, dynamic>;
            final translation = OTATranslation.fromJson(json);

            // Use cached if newer than bundled
            final bundled = _bundledTranslations[translation.locale];
            if (bundled == null || translation.version > bundled.version) {
              _cache[translation.locale] = translation;
            } else {
              _cache[translation.locale] = bundled;
            }
          } catch (e) {
            print('Error loading cached translation: $e');
          }
        }
      }

      // Add any bundled translations not in cache
      for (final entry in _bundledTranslations.entries) {
        _cache.putIfAbsent(entry.key, () => entry.value);
      }
    } catch (e) {
      // Fall back to bundled translations
      _cache.addAll(_bundledTranslations);
    }
  }

  void _startUpdateTimer() {
    _updateTimer?.cancel();
    _updateTimer = Timer.periodic(checkInterval, (_) => _checkForUpdates());
  }

  Future<void> _checkForUpdates() async {
    try {
      // Get version manifest from server
      final response = await http.get(
        Uri.parse('$baseUrl/translations/manifest.json'),
      ).timeout(Duration(seconds: 10));

      if (response.statusCode != 200) return;

      final manifest = jsonDecode(response.body) as Map<String, dynamic>;
      final remoteVersions = Map<String, int>.from(manifest['versions'] as Map);

      // Check each locale for updates
      for (final entry in remoteVersions.entries) {
        final locale = entry.key;
        final remoteVersion = entry.value;
        final cachedVersion = _cache[locale]?.version ?? 0;

        if (remoteVersion > cachedVersion) {
          await _downloadTranslation(locale);
        }
      }
    } catch (e) {
      print('Error checking for translation updates: $e');
    }
  }

  Future<void> _downloadTranslation(String locale) async {
    try {
      final response = await http.get(
        Uri.parse('$baseUrl/translations/$locale.json'),
      ).timeout(Duration(seconds: 30));

      if (response.statusCode != 200) return;

      final json = jsonDecode(response.body) as Map<String, dynamic>;
      final translation = OTATranslation.fromJson(json);

      // Update cache
      _cache[locale] = translation;

      // Persist to disk
      await _saveTranslation(translation);

      // Notify listeners
      _notifyListeners(locale);
    } catch (e) {
      print('Error downloading translation for $locale: $e');
    }
  }

  Future<void> _saveTranslation(OTATranslation translation) async {
    try {
      final directory = await getApplicationDocumentsDirectory();
      final cacheDir = Directory('${directory.path}/translations');

      if (!await cacheDir.exists()) {
        await cacheDir.create(recursive: true);
      }

      final file = File('${cacheDir.path}/${translation.locale}.json');
      await file.writeAsString(jsonEncode(translation.toJson()));
    } catch (e) {
      print('Error saving translation: $e');
    }
  }

  // Listeners for translation updates
  final List<Function(String)> _listeners = [];

  void addListener(Function(String) listener) {
    _listeners.add(listener);
  }

  void removeListener(Function(String) listener) {
    _listeners.remove(listener);
  }

  void _notifyListeners(String locale) {
    for (final listener in _listeners) {
      listener(locale);
    }
  }

  // Translation access
  String translate(String key, String locale, {String? defaultValue}) {
    final translation = _cache[locale];

    if (translation != null) {
      final value = translation.get(key);
      if (value != null) return value;
    }

    // Try fallback locale (e.g., 'en' if 'en_US' not found)
    final fallbackLocale = locale.split('_').first;
    if (fallbackLocale != locale) {
      final fallback = _cache[fallbackLocale];
      if (fallback != null) {
        final value = fallback.get(key);
        if (value != null) return value;
      }
    }

    // Try English as final fallback
    if (locale != 'en') {
      final english = _cache['en'];
      if (english != null) {
        final value = english.get(key);
        if (value != null) return value;
      }
    }

    // Return default or key
    return defaultValue ?? key;
  }

  OTATranslation? getTranslation(String locale) => _cache[locale];

  List<String> get availableLocales => _cache.keys.toList();

  int? getVersion(String locale) => _cache[locale]?.version;

  Future<void> forceUpdate() async {
    await _checkForUpdates();
  }

  void dispose() {
    _updateTimer?.cancel();
  }
}

Integration with Flutter Localization

OTA Localization Delegate

class OTALocalizationsDelegate extends LocalizationsDelegate<OTALocalizations> {
  final OTALocalizationService _service;

  OTALocalizationsDelegate(this._service);

  @override
  bool isSupported(Locale locale) {
    return _service.availableLocales.contains(locale.languageCode) ||
           _service.availableLocales.contains(locale.toString());
  }

  @override
  Future<OTALocalizations> load(Locale locale) async {
    return OTALocalizations(_service, locale);
  }

  @override
  bool shouldReload(OTALocalizationsDelegate old) => false;
}

class OTALocalizations {
  final OTALocalizationService _service;
  final Locale locale;

  OTALocalizations(this._service, this.locale);

  static OTALocalizations of(BuildContext context) {
    return Localizations.of<OTALocalizations>(context, OTALocalizations)!;
  }

  String get(String key, {String? defaultValue}) {
    return _service.translate(
      key,
      locale.toString(),
      defaultValue: defaultValue,
    );
  }

  // Convenience getters for common strings
  String get appTitle => get('app_title', defaultValue: 'My App');
  String get welcomeMessage => get('welcome_message', defaultValue: 'Welcome!');
  String get loginButton => get('login_button', defaultValue: 'Log In');
  String get logoutButton => get('logout_button', defaultValue: 'Log Out');
  String get settingsTitle => get('settings_title', defaultValue: 'Settings');
  String get languageLabel => get('language_label', defaultValue: 'Language');
  String get errorGeneric => get('error_generic', defaultValue: 'An error occurred');
}

App Integration

class MyApp extends StatefulWidget {
  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  late final OTALocalizationService _localizationService;
  Locale _currentLocale = Locale('en');
  int _translationVersion = 0;

  @override
  void initState() {
    super.initState();
    _initializeLocalization();
  }

  Future<void> _initializeLocalization() async {
    _localizationService = OTALocalizationService(
      baseUrl: 'https://api.example.com',
      bundledTranslations: {
        'en': _loadBundledEnglish(),
        'es': _loadBundledSpanish(),
        'de': _loadBundledGerman(),
      },
    );

    await _localizationService.initialize();

    // Listen for translation updates
    _localizationService.addListener(_onTranslationUpdated);

    // Load saved locale preference
    final prefs = await SharedPreferences.getInstance();
    final savedLocale = prefs.getString('locale');
    if (savedLocale != null) {
      setState(() => _currentLocale = Locale(savedLocale));
    }
  }

  void _onTranslationUpdated(String locale) {
    // Trigger rebuild when translations update
    if (locale == _currentLocale.languageCode ||
        locale == _currentLocale.toString()) {
      setState(() => _translationVersion++);
    }
  }

  OTATranslation _loadBundledEnglish() {
    return OTATranslation(
      locale: 'en',
      version: 1,
      updatedAt: DateTime(2025, 1, 1),
      translations: {
        'app_title': 'My App',
        'welcome_message': 'Welcome!',
        'login_button': 'Log In',
        'logout_button': 'Log Out',
        // ... more translations
      },
    );
  }

  OTATranslation _loadBundledSpanish() {
    return OTATranslation(
      locale: 'es',
      version: 1,
      updatedAt: DateTime(2025, 1, 1),
      translations: {
        'app_title': 'Mi App',
        'welcome_message': '¡Bienvenido!',
        'login_button': 'Iniciar Sesión',
        'logout_button': 'Cerrar Sesión',
        // ... more translations
      },
    );
  }

  OTATranslation _loadBundledGerman() {
    return OTATranslation(
      locale: 'de',
      version: 1,
      updatedAt: DateTime(2025, 1, 1),
      translations: {
        'app_title': 'Meine App',
        'welcome_message': 'Willkommen!',
        'login_button': 'Anmelden',
        'logout_button': 'Abmelden',
        // ... more translations
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      key: ValueKey(_translationVersion), // Force rebuild on translation update
      locale: _currentLocale,
      supportedLocales: [
        Locale('en'),
        Locale('es'),
        Locale('de'),
        // Add more as they become available
      ],
      localizationsDelegates: [
        OTALocalizationsDelegate(_localizationService),
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      home: HomePage(),
    );
  }

  @override
  void dispose() {
    _localizationService.dispose();
    super.dispose();
  }
}

Server-Side Implementation

Translation API Endpoints

// Example using shelf for Dart backend
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';

class TranslationApi {
  final TranslationRepository _repository;

  TranslationApi(this._repository);

  Router get router {
    final router = Router();

    // Get version manifest
    router.get('/translations/manifest.json', _getManifest);

    // Get translations for locale
    router.get('/translations/<locale>.json', _getTranslations);

    // Update translations (admin only)
    router.put('/translations/<locale>.json', _updateTranslations);

    return router;
  }

  Future<Response> _getManifest(Request request) async {
    final versions = await _repository.getAllVersions();

    return Response.ok(
      jsonEncode({
        'versions': versions,
        'updatedAt': DateTime.now().toIso8601String(),
      }),
      headers: {
        'Content-Type': 'application/json',
        'Cache-Control': 'max-age=300', // Cache for 5 minutes
      },
    );
  }

  Future<Response> _getTranslations(Request request, String locale) async {
    final translation = await _repository.getTranslation(locale);

    if (translation == null) {
      return Response.notFound('Translation not found');
    }

    final etag = 'v${translation.version}';
    final ifNoneMatch = request.headers['if-none-match'];

    // Return 304 if client has current version
    if (ifNoneMatch == etag) {
      return Response(304);
    }

    return Response.ok(
      jsonEncode(translation.toJson()),
      headers: {
        'Content-Type': 'application/json',
        'ETag': etag,
        'Cache-Control': 'max-age=3600', // Cache for 1 hour
      },
    );
  }

  Future<Response> _updateTranslations(Request request, String locale) async {
    // Verify admin authentication
    if (!_isAdmin(request)) {
      return Response.forbidden('Admin access required');
    }

    final body = await request.readAsString();
    final json = jsonDecode(body) as Map<String, dynamic>;

    final translation = OTATranslation(
      locale: locale,
      version: (await _repository.getVersion(locale) ?? 0) + 1,
      updatedAt: DateTime.now(),
      translations: Map<String, String>.from(json['translations'] as Map),
    );

    await _repository.saveTranslation(translation);

    return Response.ok(jsonEncode({'success': true, 'version': translation.version}));
  }

  bool _isAdmin(Request request) {
    // Implement admin authentication
    return true;
  }
}

CDN Configuration

# Example CloudFront configuration
CloudFrontDistribution:
  Origins:
    - DomainName: translations-bucket.s3.amazonaws.com
      S3OriginConfig:
        OriginAccessIdentity: origin-access-identity/cloudfront/XXXXX
  DefaultCacheBehavior:
    TargetOriginId: S3Origin
    ViewerProtocolPolicy: redirect-to-https
    CachePolicyId: !Ref TranslationCachePolicy
    Compress: true

TranslationCachePolicy:
  MinTTL: 300        # 5 minutes minimum
  MaxTTL: 86400      # 24 hours maximum
  DefaultTTL: 3600   # 1 hour default
  HeadersConfig:
    HeaderBehavior: whitelist
    Headers:
      - Accept-Language

Delta Updates

Efficient Partial Updates

class DeltaUpdateService {
  final OTALocalizationService _baseService;

  DeltaUpdateService(this._baseService);

  Future<void> applyDelta(String locale, Map<String, dynamic> delta) async {
    final currentTranslation = _baseService.getTranslation(locale);
    if (currentTranslation == null) return;

    final added = Map<String, String>.from(delta['added'] ?? {});
    final updated = Map<String, String>.from(delta['updated'] ?? {});
    final removed = List<String>.from(delta['removed'] ?? []);

    final newTranslations = Map<String, String>.from(
      currentTranslation.translations,
    );

    // Apply changes
    newTranslations.addAll(added);
    newTranslations.addAll(updated);
    for (final key in removed) {
      newTranslations.remove(key);
    }

    final newVersion = delta['version'] as int;

    final updatedTranslation = OTATranslation(
      locale: locale,
      version: newVersion,
      updatedAt: DateTime.now(),
      translations: newTranslations,
    );

    // Save updated translation
    await _baseService._saveTranslation(updatedTranslation);
    _baseService._cache[locale] = updatedTranslation;
    _baseService._notifyListeners(locale);
  }

  Future<void> checkForDeltaUpdates(String locale) async {
    final currentVersion = _baseService.getVersion(locale) ?? 0;

    try {
      final response = await http.get(
        Uri.parse(
          '${_baseService.baseUrl}/translations/$locale/delta?from=$currentVersion',
        ),
      );

      if (response.statusCode == 200) {
        final delta = jsonDecode(response.body) as Map<String, dynamic>;
        await applyDelta(locale, delta);
      } else if (response.statusCode == 204) {
        // No updates available
      } else if (response.statusCode == 410) {
        // Delta not available, full download required
        await _baseService._downloadTranslation(locale);
      }
    } catch (e) {
      print('Error checking for delta updates: $e');
    }
  }
}

Testing OTA Localization

Unit Tests

void main() {
  group('OTALocalizationService', () {
    late OTALocalizationService service;
    late MockHttpClient mockClient;

    setUp(() {
      mockClient = MockHttpClient();
      service = OTALocalizationService(
        baseUrl: 'https://api.example.com',
        bundledTranslations: {
          'en': OTATranslation(
            locale: 'en',
            version: 1,
            updatedAt: DateTime.now(),
            translations: {'hello': 'Hello'},
          ),
        },
      );
    });

    test('returns bundled translation when no cache', () async {
      await service.initialize();

      expect(service.translate('hello', 'en'), 'Hello');
    });

    test('updates cache when server has newer version', () async {
      when(mockClient.get(any)).thenAnswer((_) async {
        return http.Response(
          jsonEncode({
            'versions': {'en': 2},
          }),
          200,
        );
      });

      await service._checkForUpdates();

      // Should trigger download of new version
      verify(mockClient.get(
        Uri.parse('https://api.example.com/translations/en.json'),
      )).called(1);
    });

    test('falls back to English for missing keys', () async {
      await service.initialize();

      expect(
        service.translate('missing_key', 'es', defaultValue: 'fallback'),
        'fallback',
      );
    });

    test('notifies listeners on translation update', () async {
      await service.initialize();

      String? updatedLocale;
      service.addListener((locale) => updatedLocale = locale);

      service._notifyListeners('en');

      expect(updatedLocale, 'en');
    });
  });
}

Best Practices

OTA Localization Checklist

  1. Always bundle fallback translations - App must work offline
  2. Version translations - Track what version users have
  3. Use ETags/conditional requests - Reduce bandwidth
  4. Cache aggressively - Store on disk for offline use
  5. Handle errors gracefully - Never break app if update fails
  6. Test update flow - Verify hot reload works correctly
  7. Monitor analytics - Track version adoption

Security Considerations

  • Sign translation payloads to prevent tampering
  • Use HTTPS for all translation requests
  • Validate JSON structure before parsing
  • Sanitize translations to prevent XSS

Conclusion

OTA localization gives you the flexibility to update translations instantly without app store releases. By combining bundled fallback translations with server-side updates, you get the best of both worlds: reliability and agility. Implement proper caching, versioning, and error handling to ensure a smooth experience.

Related Resources