Runtime Locale Switching in iOS, Android, and React Native: 6 Ways It Breaks Your App (And How to Handle Each)
Runtime locale switching used to be an edge case — the kind of thing that showed up in bug reports from that one power user who changes their phone language every other week. With iOS 16 and Android 13 introducing per-app language settings, it's now a first-class user expectation. If your app can't handle a locale switch without a restart, you're already behind.
This is the second post in a three-part series on platform-specific i18n runtime behavior. The first post covered platform-specific localization gotchas across iOS, Android, and React Native. The third will cover build-time locale validation in CI. Here, we're focused on what happens after a user changes their language mid-session — and the six distinct ways that breaks things.
A correctly formatted string is useless if the locale state driving it is stale. Let's get into it.
The 6 Failure Modes
1. Stale String Bundles (iOS)
iOS caches Bundle.main aggressively. When a user switches locale — whether through the system Settings app or, more relevantly now, through the per-app language picker introduced in iOS 16 — Bundle.main does not automatically reload. The localizedString(forKey:value:table:) calls you're making will keep returning strings from whatever language bundle was loaded at app start.
The standard workaround is to force a fresh bundle load using Bundle(path:):
// Instead of using Bundle.main directly:
extension Bundle {
static var localized: Bundle {
guard
let languageCode = UserDefaults.standard.string(forKey: "AppleLanguages")
.flatMap({ (UserDefaults.standard.array(forKey: "AppleLanguages") as? [String])?.first }),
let path = Bundle.main.path(forResource: languageCode, ofType: "lproj"),
let bundle = Bundle(path: path)
else {
return Bundle.main
}
return bundle
}
}
// Usage:
let greeting = Bundle.localized.localizedString(forKey: "welcome_message", value: nil, table: nil)
The tradeoff: you're now bypassing the system's language fallback chain. If a key is missing in the target language, you won't automatically get the development language fallback — you'll get the value parameter or an empty string. You need to implement your own fallback logic if you go this route.
For per-app language settings (iOS 16+), Apple's CFBundleAllowMixedLocalizations entitlement and the appleLanguages preference key interact in ways that are worth reading carefully in the docs. The short version: if your app uses SceneDelegate, you may need to reset the entire scene rather than just swapping bundle references. Apps with a single UIWindow per scene have more control here; document-based apps are a different story.
The cleanest architecture for this on iOS is to route all string lookups through a single LocalizationService that holds a reference to the current bundle and can be signaled to refresh. Avoid sprinkling NSLocalizedString calls directly throughout your view code.
2. Activity Recreation Gaps (Android)
Android's standard behavior when a locale changes is to recreate the current Activity — which triggers a full layout reinflation and reloads string resources from the correct locale. This sounds fine until you account for the fragment backstack.
A fragment created before the locale switch was rendered with the old locale's layout measurements, string resources, and potentially RTL/LTR direction. When the activity recreates, fragments deep in the backstack are restored from their saved state, not recreated from scratch. This means a user who navigated three screens deep, switched language in a settings modal, and then pressed Back will land on screens still rendering in the old language.
AppCompatDelegate.setApplicationLocales() (Android 13+, backported to API 14 via appcompat:1.6+) handles the system-level preference update cleanly, but it doesn't solve the backstack problem for you:
// Setting the locale (Android 13+ / AppCompat backport)
val localeList = LocaleListCompat.forLanguageTags("ar")
AppCompatDelegate.setApplicationLocales(localeList)
// This triggers Activity recreation — but you still need to handle the backstack.
// The safest approach: clear the backstack before switching.
fun switchLocaleAndRestart(languageTag: String, context: Context) {
val localeList = LocaleListCompat.forLanguageTags(languageTag)
AppCompatDelegate.setApplicationLocales(localeList)
// Clear the entire task and restart from root
val intent = context.packageManager
.getLaunchIntentForPackage(context.packageName)
?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
context.startActivity(intent)
}
For legacy approaches (pre-Android 13, or if you're still manually calling updateConfiguration), you have to be even more deliberate:
// Legacy locale switch — override in every Activity's attachBaseContext
override fun attachBaseContext(newBase: Context) {
val locale = Locale(preferenceManager.getLanguage())
val config = Configuration(newBase.resources.configuration).apply {
setLocale(locale)
}
super.attachBaseContext(newBase.createConfigurationContext(config))
}
The fragment backstack mismatch is a legitimate UX problem and there's no elegant solution that avoids clearing it. If you want to preserve navigation state across a locale switch, you'll need to save and restore that state manually after the recreation — which is more engineering work than most teams budget for. The pragmatic answer for most apps is to make the locale switch explicit, tell the user the app will restart, and do a clean relaunch.
Switching from English to Arabic at runtime isn't just a string swap. The entire layout direction needs to flip — and if your view hierarchy is already rendered, it won't do so automatically on any platform.
iOS: UIView.semanticContentAttribute controls the layout direction per-view. Views already on screen retain the value they were given at init time. After a locale switch, you need to either reload the view controller stack or explicitly set semanticContentAttribute on affected views:
// Force RTL layout direction after locale switch
UIView.appearance().semanticContentAttribute = .forceRightToLeft
// This affects new views, not existing ones.
// For existing views, you'll need to call setNeedsLayout() on each
// or push a new view controller.
See the RTL pre-release checklist for a fuller treatment of what layout direction changes actually affect in a typical iOS view hierarchy.
Android: LayoutInflater caches view creation, and the layoutDirection attribute is baked in at inflate time. Even if you update the configuration, previously inflated views don't re-measure or re-layout for the new direction without explicit invalidation. Clearing the backstack and recreating activities (as described above) is the correct fix here. Do not try to manually flip layoutDirection on live views — you'll miss edge cases.
React Native: I18nManager.forceRTL() is the relevant API, and it explicitly requires an app reload to take effect. This isn't a limitation you can work around:
import { I18nManager } from 'react-native';
import RNRestart from 'react-native-restart'; // or your equivalent reload mechanism
function switchToRTL() {
if (!I18nManager.isRTL) {
I18nManager.forceRTL(true);
// A reload is required — inform the user before doing this
RNRestart.Restart();
}
}
The reason a reload is required is that React Native's layout engine (Yoga) bakes the isRTL flag into the computed layout at the point the component tree is first rendered. Flipping the flag post-render doesn't trigger a re-layout pass for already-mounted components. Handle this gracefully: show a dialog, explain that a restart is needed, and reload. Don't silently restart — that's a confusing experience.
This one is quiet and insidious. You initialize a DateFormatter or NumberFormatter at app start — reasonably enough, since they're expensive to create — and stash it in a singleton or static property. The formatter captures the locale at initialization time. After a runtime locale switch, every date and number in your app is still formatting under the old locale.
// ❌ Problematic: locale is captured at init
class Formatters {
static let date: DateFormatter = {
let f = DateFormatter()
f.locale = Locale.current // This is the locale at app start, not the current runtime locale
f.dateStyle = .medium
return f
}()
}
// ✅ Better: factory pattern, locale resolved at call time
class Formatters {
static func date(for locale: Locale = .current) -> DateFormatter {
let f = DateFormatter()
f.locale = locale
f.dateStyle = .medium
return f
}
}
On Android, the same issue applies to java.text.DateFormat and java.text.NumberFormat instances, as well as any Kotlin DateTimeFormatter initialized with an explicit locale:
// ❌ Singleton with captured locale
object Formatters {
val currency: NumberFormat = NumberFormat.getCurrencyInstance(Locale.getDefault())
// Locale.getDefault() is evaluated once, at init
}
// ✅ Factory approach
object Formatters {
fun currency(): NumberFormat = NumberFormat.getCurrencyInstance(Locale.getDefault())
// Locale.getDefault() is evaluated each time the function is called
}
To audit your codebase for stale formatter singletons, search for DateFormatter(), NumberFormatter(), DateFormat.get, and NumberFormat.get in static or lazy contexts. Any formatter initialized outside of a function call — as a property initializer, in an object, or in a companion object — is a candidate for this bug.
The user switches language in your app. The UI updates (assuming you've handled the above). But they open a push notification — the text is still in the old language. They check a remote config string — wrong language. The Accept-Language header on your HTTP client was set at startup and never updated.
This is a common enough pattern that it deserves its own architecture consideration. The fix is interceptor-based locale injection — resolve the locale at request time, not at client initialization time.
Retrofit (Android):
class LocaleInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val locale = AppLocaleManager.currentLocale() // Your runtime locale source of truth
val request = chain.request().newBuilder()
.header("Accept-Language", locale.toLanguageTag())
.build()
return chain.proceed(request)
}
}
// Register it:
OkHttpClient.Builder()
.addInterceptor(LocaleInterceptor())
.build()
Alamofire (iOS):
final class LocaleAdapter: RequestAdapter {
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
var request = urlRequest
let languageTag = LocaleManager.shared.currentLocale.identifier
request.setValue(languageTag, forHTTPHeaderField: "Accept-Language")
completion(.success(request))
}
}
React Native (fetch/axios):
// Axios interceptor
axios.interceptors.request.use((config) => {
config.headers['Accept-Language'] = LocaleManager.currentLocale();
return config;
});
The key in all three cases: LocaleManager.currentLocale() (or your equivalent) must be evaluated at request time, not captured into a closure or stored in the interceptor's properties at initialization. The interceptor is just a hook — the locale resolution must be dynamic.
Server-rendered content like push notification text is a harder problem, because the notification payload is generated at send time on the server. The only reliable fix is to ensure your backend has the user's current locale preference stored and uses it when generating notifications. This means your locale-switch event needs to propagate to your backend — either via an API call or by updating a user preference field. It's more infrastructure than most teams plan for, but it's the correct long-term architecture.
6. React Native Bridge Locale Desync
React Native apps run JavaScript on a JS thread and communicate with native code across a bridge (or JSI in newer architectures). After a locale switch, these two sides can hold different locale values if you're not careful about where the source of truth lives.
A common manifestation: you switch locale in JS, update your i18n context, and render strings correctly in React components. But a native module you call — say, a date picker, a payment sheet, or a native screen — still formats output in the old locale because it reads from the native locale state that hasn't been updated.
The reverse also happens: the user changes their device locale while the app is backgrounded. The native side reflects the new locale when the app comes to foreground, but your JS Intl objects and i18n context still hold the old value.
The reliable fix is a single source of truth that both sides agree on:
// LocaleManager.js — the JS-side source of truth
import { NativeModules, NativeEventEmitter } from 'react-native';
const { RNLocaleModule } = NativeModules;
const eventEmitter = new NativeEventEmitter(RNLocaleModule);
class LocaleManager {
constructor() {
this._locale = RNLocaleModule.getLocale(); // Synchronous read at init
this._listeners = [];
// Listen for native-side locale changes (e.g., OS locale change while backgrounded)
eventEmitter.addListener('localeChanged', (newLocale) => {
this._locale = newLocale;
this._listeners.forEach(fn => fn(newLocale));
});
}
currentLocale() {
return this._locale;
}
setLocale(locale) {
this._locale = locale;
RNLocaleModule.setLocale(locale); // Propagate to native side
this._listeners.forEach(fn => fn(locale));
}
onChange(listener) {
this._listeners.push(listener);
return () => {
this._listeners = this._listeners.filter(fn => fn !== listener);
};
}
}
export default new LocaleManager();
On the native side, RNLocaleModule.setLocale() updates the locale used by any native modules that format dates, numbers, or text. The event emission handles the OS-change case. The important invariant: neither side reads from Locale.current / Locale.getDefault() directly in a way that can diverge from JS. Everything goes through the bridge-aware manager.
This is also where Intl diverges from NativeModules. Intl in Hermes resolves locale from the JS environment, which may not reflect what the native side reports after a mid-session change. If you're using Intl.DateTimeFormat or Intl.NumberFormat directly, instantiate them with an explicit locale tag rather than relying on the implicit default.
Testing Runtime Locale Switching
Automated testing for runtime locale switches is feasible but requires deliberate setup. The approach on each platform:
-
iOS (XCUITest): Launch with XCUIApplication.launchArguments set to a specific locale, interact, then terminate and relaunch with a different locale. True mid-session switching via XCUITest is constrained by Apple's testing APIs, but you can simulate the backstack/state scenarios by saving and restoring state between test phases.
-
Android (Espresso): Use ActivityScenario and AppCompatDelegate.setApplicationLocales() directly in your test. Espresso can handle activity recreation cleanly, which lets you write tests that exercise the fragment backstack mismatch described above.
-
React Native (Detox): Detox supports locale configuration at launch and has hooks for simulating app backgrounding. Test the bridge desync scenario explicitly by reading a locale-dependent value from a native module immediately after a JS-side locale switch.
The device configuration scenario that nearly every team misses: switching locale while the app is backgrounded. When the app returns to foreground, the native side has the new locale, your JS context has the old one, and any views that were kept in memory have stale layout direction. This is the scenario that causes the most confusing bug reports because it's hard to reproduce deliberately — testers don't think to switch language and then come back to the app.
For complementary testing approaches including pseudo-localization and RTL validation, see the localization testing guide and the RTL layout testing checklist.
Conclusion
Here's a quick reference for which failure modes apply where:
| Failure Mode |
iOS |
Android |
React Native |
| Stale string bundles |
✓ |
— |
partial |
| Activity recreation gaps |
— |
✓ |
— |
| RTL/LTR not reapplying |
✓ |
✓ |
✓ |
| Cached formatter singletons |
✓ |
✓ |
✓ |
| Stale Accept-Language headers |
✓ |
✓ |
✓ |
| Bridge locale desync |
— |
— |
✓ |
A few of these — specifically the networking interceptor pattern and formatter factory pattern — are solved once and rarely revisited. The harder ones (RTL reapplication, bridge desync, backstack gaps) require architectural decisions upfront that are expensive to retrofit.
A managed localization platform handles translation content, memory, and consistency, but runtime locale state is your code's responsibility. Tools like GetTranslated.AI ensure your translated strings are accurate and structurally sound before they reach your app — the failure modes covered here happen downstream of translation, in the runtime plumbing that decides which string gets shown and when.
The third post in this series covers build-time locale validation in CI: catching missing keys, invalid placeholders, and locale-specific formatting errors before they reach a device at all.