Offline & Caching
How persistent caching and offline fallback works in @better-i18n/expo
Mobile apps need to work without network. @better-i18n/expo uses a network-first strategy with persistent storage so your app always gets fresh translations — and never crashes when the CDN is unavailable.
How It Works
When you call initBetterI18n() or changeLanguage(), the SDK follows this flow:
initBetterI18n() / changeLanguage()
│
├─ 1. Resource check (hasResourceBundle?)
│ └─ Already loaded? → Skip fetch, switch instantly
│
├─ 2. CDN fetch (network-first)
│ ├─ Success → Inject resources + write to persistent cache
│ └─ Failure ↓
│
├─ 3. Persistent cache (via storageAdapter)
│ ├─ Cached? → Inject resources, switch language ← covers most offline cases
│ └─ No cache ↓
│
└─ 4. Static data (advanced, optional)
├─ Has locale? → Use bundled translations ← only needed without persistent storage
└─ No data → i18next uses fallbackLng (graceful degradation)Most apps only need steps 1–3. Step 4 (staticData) is for edge cases — see Advanced: Static Data Fallback below.
Key guarantee: If the user has opened the app at least once with network, the app will always have translations available — even in airplane mode.
Cache Layers
1. Resource Store (In-Memory)
The fastest layer. Once translations are loaded into i18next's resource store, subsequent changeLanguage() calls skip the CDN entirely.
- Survives navigation and re-renders
- Cleared when the app is killed
- Checked via
hasResourceBundle()before any network call
2. Persistent Cache
Translations are persisted to device storage so they survive app restarts. Used as offline fallback when the CDN is unreachable.
Storage keys (per locale):
@better-i18n:{project}:{locale}:translations → JSON translation data
@better-i18n:{project}:{locale}:meta → { cachedAt: timestamp }Each locale has a single cache entry that gets overwritten on every successful CDN fetch — no duplicate entries.
Advanced: Static Data Fallback
Most apps don't need this. If you use MMKV or AsyncStorage persistent storage, staticData is unnecessary — persistent storage already covers all offline scenarios including first launch (after the first successful fetch).
staticData is only needed for edge cases: when you cannot use persistent storage, or when you want to guarantee translations are available even before the very first network connection.
For airplane mode on first launch where there's no CDN and no cache, you can bundle translations directly in your app:
await initBetterI18n({
project: 'your-org/your-project',
i18n: i18n.use(initReactI18next),
staticData: {
en: { common: { welcome: 'Welcome' } },
tr: { common: { welcome: 'Hoş geldiniz' } },
},
});These translations are passed to @better-i18n/core and used only when both CDN and persistent cache fail. Because they're bundled in your app binary, they're always available — but they also increase your bundle size and may go stale between app releases.
Persistent Storage
Persistent storage olmadan çeviriler yalnızca bellekte tutulur — uygulama kapandığında kaybolur.
storageAdapter() ile MMKV veya AsyncStorage geçerek offline desteği etkinleştirin.
storageAdapter() ile (Recommended)
storageAdapter() wraps MMKV or AsyncStorage automatically — no manual interface implementation needed.
import { MMKV } from 'react-native-mmkv';
import { initBetterI18n, storageAdapter } from '@better-i18n/expo';
const mmkv = new MMKV({ id: 'app' });
await initBetterI18n({
project: 'acme/app',
i18n,
storage: storageAdapter(mmkv, { localeKey: '@app:locale' }),
});import AsyncStorage from '@react-native-async-storage/async-storage';
import { initBetterI18n, storageAdapter } from '@better-i18n/expo';
await initBetterI18n({
project: 'acme/app',
i18n,
storage: storageAdapter(AsyncStorage, { localeKey: '@app:locale' }),
});MMKV and AsyncStorage are equal choices. MMKV is faster with synchronous I/O; AsyncStorage works out of the box with Expo Go and requires no native build step.
Manual Implementation
For a fully custom store, implement the TranslationStorage interface directly:
await initBetterI18n({
project: 'acme/app',
i18n,
storage: {
getItem: async (key: string) => /* ... */,
setItem: async (key: string, value: string) => /* ... */,
removeItem: async (key: string) => /* ... */,
},
});Locale Persistence
By default, locale selection is not persisted across app restarts. Pass localeKey to storageAdapter() to enable automatic locale persistence.
Without localeKey
The user's language selection is lost when the app is killed. On next launch, the startup locale is resolved in this order:
Startup locale resolution (no localeKey):
1. getDeviceLocale() ← if useDeviceLocale: true
2. defaultLocale ← fallbackWith localeKey
When localeKey is set, storageAdapter() returns a LocaleAwareTranslationStorage that reads and writes the active locale alongside translation data. On startup:
Startup locale resolution (with localeKey):
1. storage.readLocale() ← user's saved preference (wins if set)
2. getDeviceLocale() ← if useDeviceLocale: true
3. defaultLocale ← fallbackinitBetterI18n detects the readLocale / writeLocale methods via duck-type check at runtime — no extra configuration required.
Without localeKey, the user's language choice resets to defaultLocale on every app restart — even if they previously selected a different language. For production apps, always pass localeKey to storageAdapter().
In-Memory Only
For testing or when you don't want persistence:
import { createMemoryStorage } from '@better-i18n/expo';
await initBetterI18n({
project: 'acme/app',
i18n,
storage: createMemoryStorage(),
});Without Persistent Storage
If no storage is provided, the SDK automatically uses in-memory storage. This means:
- Translations are fetched from CDN on every app launch
- No offline support between sessions
- Works fine for development and testing
When started with debug: true, the SDK logs a warning:
[better-i18n/expo] No persistent storage provided. Translations won't survive app restarts.
Pass a storage adapter (e.g., storageAdapter(new MMKV())) for offline support.In-memory fallback is suitable for development and testing.
For production apps, always pass storageAdapter().