In-House Over-the-Air Localizations in a Modular iOS App

How we built a minimalistic, elegant and effective remote localization system for the Just Eat Takeaway consumer app.

In-House Over-the-Air Localizations in a Modular iOS App
Originally published on the Just Eat Takeaway Engineering Blog.

Localizations are one of the few things in an iOS app that most teams quietly accept as immovable. Strings live in .strings and .stringsdict files, those files are compiled into the app bundle, and the app bundle is what the App Store serves. A wrong translation is therefore a release-cycle problem: someone spots it, someone fixes it in the translation management platform, the fix is pulled into the repository, and then everybody waits for the next release train which could take from days to weeks.

The mobile industry has grown used to changing behaviour remotely: feature flags, remote configuration, server-driven UI, even debatable usage of web views to present remote content, while the copy that surrounds all of it stays frozen at build time. Some localization platforms do offer an over-the-air (OTA) mechanism bundled into their SDK, and if you happen to use one of those, the problem is largely solved for you. The platform we adopted at Just Eat Takeaway.com does not, so we built our own.

I want to be clear that this turned out to be a feature, not a compromise. An SDK-provided OTA solution comes with the vendor's assumptions about how your app resolves strings, and those assumptions rarely accommodate a monorepo of 30+ modules where each one owns its own resource bundle. Building it ourselves meant the design could follow the shape of our modular architecture rather than fight it. The result is a small foundation module with no dependencies, roughly a few hundred lines of client code, a one-line change per module, and a feature flag that enable the capability in the entire app.

This article describes our design.

Use Cases

Three use cases drove the work, and we treated them as equal in priority.

Fixing incorrect translations in production. A translator submits a wrong string which gets shipped. The correction requires an app release while we wanted it to just require a publish.

Faster localization of new features. Translations arrive on their own schedule, which is not the release schedule. Being able to deliver and revise them independently decouples two processes that were never really coupled by anything except the build system.

Copy control for Product and Marketing. Labels, prompts, error messages and empty-state copy get iterated on. Every iteration currently requires a new release.

From those, the goals followed:

  • Download updated string bundles at runtime and apply them on the next cold launch.
  • No unnecessary network traffic. The mechanism runs on every launch and every foreground transition, so the common case where nothing has changed must cost approximately nothing.
  • No risk of a partial or corrupted string bundle reaching the app. A half-written bundle is worse than no bundle.
  • Transparent fallback to the embedded strings whenever OTA content is unavailable (e.g. on first run, upon download failures, when the feature flag is disabled).
  • A real kill switch. Not "stop downloading", but "behave exactly as if this feature had never been built".

And one explicit non-goal: live, mid-session string updates. I will come back to why.

An initial constraint

There is exactly one set of OTA strings on the CDN for a given app version, and it applies to every device running that version. This has a consequence worth stating plainly, because it is the sharpest edge in the whole design:

A string key must never be reused with a different meaning.

For example, if version 1.2.0 uses basket.cta.title for "Go to checkout" and version 1.3.0 reuses the same key for "Review your order", a publish that corrects one breaks the other. In practice this is already standard hygiene since nobody deliberately recycles keys. Moreover, the CDN layout described below allows versioning which reduces the blast radius of bad deployments.

The insight: Bundle already does the hard part

A temptation, when one first sketches this, might be to build a string store: download JSON, parse it into a dictionary, and route every localizedString(_:) call through your own lookup with your own locale-matching logic. I have seen that shape a couple times and while it doesn't deliver successfully, it's also an unnecessary source of complexity: hand-rolled reimplementation of locale fallback chains, region variants, .stringsdict plural rules, etc.

Bundle already does all of that, correctly. It handles locale selection, the fallback chain from specific languages to more generic (e.g. fr-CA to fr) to the base language, and the plural rules in .stringsdict.

NSLocalizedString() conveniently takes a Bundle parameter. Similarly, in SwiftUI, the bundle can be passed to the relevant init, e.g. for Text: Text("basket.cta.title", bundle: <custom_bundle>).

So the entire client design collapses into one sentence: download a directory that looks like a resource bundle, and pass it to NSLocalizedString instead of the embedded one.

Every module in our monorepo resolves its strings through a LocalizableStrings protocol extension.

Before:

extension Bundle {
    class var myModule: Bundle {
        Bundle.module
    }
}

protocol LocalizableStrings {
    func localizedString(_ key: String, comment: String) -> String
}

extension LocalizableStrings {
    func localizedString(_ key: String, comment: String) -> String {
        NSLocalizedString(key, bundle: .myModule, comment: comment)
    }
}

After:

import OTALocalizations

extension Bundle {
    ...
    
    class var localizationBundle: Bundle {
        OTALocalizedString.bundle(for: "MyModule") ?? .myModule
    }
}

...

extension LocalizableStrings {
    func localizedString(_ key: String, comment: String) -> String {
        NSLocalizedString(key, bundle: .localizationBundle, comment: comment)
    }
}

That is the integration. One line per module, and the nil-coalescing operator giving us the entire fallback logic: when there is no OTA bundle on disk, bundle(for:) returns nil and the module uses the strings it was compiled with. No branching on a feature flag, no error handling, no awareness of the network. A module that has never heard of OTA behaves identically whether the feature is on or off.

In the Just Eat Takeaway.com app, roughly thirty modules plus the main app now go through this path.

The server side

The .strings and .stringsdict files committed to the app repository are the source of truth for publishing. They have already been through the full process: translated on the localization platform, pulled by an automated workflow, reviewed in a pull request. Publishing from the app repository rather than directly from the translation platform means only vetted content reaches production. It also means the publish step lives next to the files it publishes, which removes an entire class of synchronisation bugs.

A CI workflow does four things:

1. For each module that has localizations, zips its .lproj directories into a single archive with the name of the component, e.g. Checkout.zip containing en-GB.lproj/Localizable.strings, en-GB.lproj/Localizable.stringsdict, fr-FR.lproj/Localizable.strings, and so on. It’s particularly important that an Info.plist file with CFBundleDevelopmentRegion set to be present in the bundle so that iOS can correctly map Base.lproj to the default language (in our case English).

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleDevelopmentRegion</key>
    <string>en</string>
    <key>CFBundleInfoDictionaryVersion</key>
    <string>6.0</string>
    <key>CFBundlePackageType</key>
    <string>BNDL</string>
</dict>
</plist>

2. Computes a SHA-256 content hash of each archive.

3. Uploads the archives at a location scoped to the app's marketing version.

4. Generates and uploads manifest.json alongside them.

{
  "publishedAt": "2026-07-24T10:00:00Z",
  "modules": [
    { "name": "App",      "path": "App.zip",      "sha256": "d3f9cd1e..." },
    { "name": "Account",  "path": "Account.zip",  "sha256": "8b1a2c3f..." },
    { "name": "Checkout", "path": "Checkout.zip", "sha256": "f4e2d1a0..." }
  ]
}

The generated resources are static content behind a CloudFront CDN, with Cache-Control and ETag headers passed through from object storage with the infrastructure defined in Terraform in a repository of its own. There is no service to run and nothing to scale; a manifest fetch that finds nothing new simply results in a 304 with no body.

The workflow is triggered automatically when a release is uploaded to TestFlight/App Store Connect, so strings are always published for a shipping version, and manually for emergency corrections against a version already in the field.

One file is deliberately excluded: InfoPlist.strings. It holds the app display name and the permission-request descriptions, which iOS reads from the app bundle at the OS level, outside any code we control. No downloaded bundle can override it, so pretending otherwise would only create a confusing failure mode. For this reason, only Localizable.strings and Localizable.stringsdict are published.

The client module

All of the OTA logic lives in a dedicated Swift package, named OTALocalizations, declared with category: foundation with no local and no remote dependencies at all. See the article Revisiting the Just Eat Takeaway iOS Modular Architecture in 2026 to understand how we categorise our modules.

The module offers 2 public APIs.

OTALocalizationService

An actor, registered with our dependency resolver at app level, configured with the CDN host and the app's marketing version:

public protocol OTALocalizationServicing: AnyObject, Sendable {
    func start() async
    func deleteAllBundles() async
}

public actor OTALocalizationService: OTALocalizationServicing {
    public init(baseURL: URL, appVersion: String, logger: any Logging)
    // ...
}

Feature modules never see this type. It exists at app level, where the feature flag and the app version are both available, and it is the only thing in the system that touches the network or writes to disk.

OTALocalizedString

A stateless struct with a synchronous, non-throwing read path. It knows nothing about the network, the feature flag, or the service:

public struct OTALocalizedString {
    @MainActor public static func loadBundlesFromDisk()
    @MainActor public static func clearBundleCache()
    public static func bundle(for componentName: String) -> Bundle?
}

bundle(for:) is called from inside every localized string lookup in the app, which means it is called thousands of times during a launch. It has to be a plain dictionary read. It cannot be async, it cannot be @MainActor-isolated, and it cannot throw, because NSLocalizedString sits all over the codebase.

That produces the one genuinely delicate piece of concurrency in the module:

// All writes (loadBundlesFromDisk, clearBundleCache) are @MainActor.
// There is no concurrent read/write window in practice.
// nonisolated(unsafe) avoids a @MainActor requirement on the hot bundle(for:)
// read path, which must remain a plain synchronous call for use in NSLocalizedString.
private nonisolated(unsafe) static var bundleCache: [String: Bundle] = [:]

nonisolated(unsafe) is an escape hatch and should be treated as one. It is defensible here for a specific reason: both writers are @MainActor. The alternatives were a lock on the hottest read path in the app, or making every localized string access @MainActor, which would have rippled through thirty modules.

Why not a singleton

A singleton is the reflex for a service reached across module boundaries, and it is very much not advisable. It resists configuration since the CDN host and app version must be injected at app level and shared mutable global state is a well-known source of trouble.

The split above avoids the question entirely. The thing that needs configuration (OTALocalizationService) is injected and never crosses a module boundary. The thing that crosses module boundaries (OTALocalizedString) needs no configuration, because it reads from a well-known path in Library/Application Support/ that it can derive on its own.

The download cycle

start() runs one cycle:

  1. Create the root directory if needed, setting isExcludedFromBackup = true: OTA bundles are re-downloadable, so there is no reason to grow the user's iCloud backup with them.
  2. Fetch manifest.json with cachePolicy: .useProtocolCachePolicy. URLSession handles conditional requests for us; a 304 surfaces as OTAError.notModified and ends the cycle.
  3. Compare each module's sha256 against the locally persisted manifest. Matching hash, skip.
  4. For each changed module, download and extract atomically.
  5. Persist the new manifest only if every module succeeded.

Points 4 and 5 fulfill the "no partial bundles" requirement.

Extraction never writes into the live bundle directory. The archive is downloaded to a temporary file, extracted into a temporary directory, and only then swapped in:

if fileManager.fileExists(atPath: targetURL.path) {
    _ = try fileManager.replaceItemAt(targetURL, withItemAt: tempExtractDir,
                                      backupItemName: nil, options: [])
} else {
    try fileManager.moveItem(at: tempExtractDir, to: targetURL)
}

replaceItemAt is an OS-level atomic replace. A crash, a kill, or a battery death at any point during the download or the extraction leaves the previous bundle intact. There is no moment at which a bundle contains three of its eight locales.

This is also why we download one archive per module rather than individual .strings files. Per-file downloads would be marginally cheaper on bandwidth and would open exactly the window we are trying to close: a bundle in which some locale files have been updated and others have not.

Failure is per-module and survivable:

do {
    try await bundleDownloader.downloadAndExtract(componentName: entry.name, path: entry.path)
    downloaded += 1
} catch {
    // OTA is best-effort: a single module failure must not abort the whole cycle.
    failed += 1
}

and the persisted manifest is the retry mechanism:

// Only persist the new manifest when every module was fetched successfully,
// so that failed modules are retried on the next cycle.
if failed == 0 {
    try store.saveManifest(manifest)
}

For example, if Checkout.zip fails while the others succeed, the successful bundles are already on disk and usable while the manifest is not advanced. The next cycle rechecks everything, finds that all but one hash matches, skips the one that doesn’t, and retries the one that failed. The cost of not persisting is one extra manifest comparison; the benefit is that a transient failure heals itself without any retry bookkeeping.

Two more details on the actor. Concurrent start() calls coalesce rather than racing:

if let existing = inFlightTask {
    await existing.value
    return
}

and deleteAllBundles() cancels the in-flight task and waits for it before wiping the store, so a download in progress cannot resurrect files immediately after deletion. That ordering bug is easy to write and unpleasant to diagnose, since it only manifests when a user disables the feature at the exact moment a download is landing.

Fetch on foreground, apply on cold launch

Downloaded bundles hit the disk as soon as they arrive. The in-memory Bundle objects that bundle(for:) serves are loaded exactly once, early in didFinishLaunching, before the first view renders:

self.didFinishLaunchingTask = Task { @MainActor in
    // Load any previously downloaded OTA bundles into memory before the first view renders
    OTALocalizedString.loadBundlesFromDisk()
    // ...
    otaLocalizationsOperation() // fire and forget

Fetching happens on both cold launch and every foreground transition; applying happens only on cold launch. The asymmetry is intentional. Fetching on foreground means that by the time the user next cold-launches, fresh strings are already sitting on disk making the update effectively pre-warmed, and the ETag mechanism makes the check nearly free when nothing has changed. Applying only on cold launch means strings never change under a user's fingers.

We considered live mid-session updates and rejected them without much hesitation. Making it work properly would require every localized string in the app to become an observable binding, which is a very large change for a benefit measured in minutes; and the user-facing behaviour of labels mutating while someone is reading a screen should be considered more a bug than a feature. Cold-launch application satisfies all three of our use cases.

The flag handling lives in the same place, and it allows us to have a kill switch:

func otaLocalizationsOperation() {
    Task { @MainActor in
        await tryResolver {
            let toggleAccessor = try self.resolver.protectedModule(ToggleAccessor.self) // via Toggles https://github.com/TogglesPlatform/Toggles
            guard toggleAccessor.isOTALocalizationsEnabled else {
                OTALocalizedString.clearBundleCache()
                let service = try self.resolver.module((any OTALocalizationServicing).self)
                await service.deleteAllBundles()
                return
            }
            let service = try self.resolver.module((any OTALocalizationServicing).self)
            await service.start()
        }
    }
}

Turning the flag off does not merely stop downloads. It clears the in-memory cache immediately, so the current session reverts to embedded strings, and deletes everything on disk, so the next cold launch finds nothing and bundle(for:) returns nil everywhere. There is no state left for OTA behaviour to leak through. Ordering matters here and we clear the cache before deleting the files, so no Bundle instance is left pointing at a directory that is being removed underneath it.

The zip extractor

A foundation module with zero dependencies needs to unzip archives, and Foundation does not offer a public API for that. So there is a hand-rolled extractor: about two hundred lines that parse the ZIP central directory and inflate entries with zlib.

Writing your own archive parser is not a decision to take lightly, and I would not have done it to save a dependency in a leaf feature module: adding a third-party package to a module that thirty other modules import puts that package in every dependency graph in the repository, and the scope we actually need is tiny.

Thanks to AI, generating a solid and tested zip extractor in Swift was a breeze and we could avoid relying on ZIP Foundation which seemed to be the only other reasonable way to go.

A note on String Catalogs

Xcode 15 introduced the String Catalog (.xcstrings), a JSON-based format that unifies .strings and .stringsdict into a single file with better tooling support that does not feature in this design.

String Catalog is a source-authoring format. Xcode compiles it into .strings and .stringsdict at build time, and those compiled files are what ends up in the app bundle. At runtime, Bundle and NSLocalizedString see only the compiled output and there is no public API to load a .xcstrings file. The format is invisible by the time the app is running.

String Catalog does not help with the original problem: strings are still compiled into the bundle at build time, and a wrong translation still requires a release to correct. The OTA mechanism addresses that by operating at a different layer.

We have not made the migration to String Catalog and the honest reason is that the return on investment is not obvious enough to justify the work across the codebase. String Catalog offers a better DevEx but does not introduce any real new capability. The strings it produces are the same strings we already have. The cost of a coordinated migration, however mechanical, is not negligible, the benefit is ergonomic rather than functional and it would be a separate decision from OTA entirely.

Rejected alternatives

Some of the more interesting decisions were about what not to build.

Pulling strings from the translation platform's API at runtime. This would have required embedding platform credentials in the app, and the platform's API is not a CDN and is not intended to serve millions of clients. Worse, it removes the review gate: a translator's mistake would reach production the instant it was saved.

Method swizzling on Bundle. Attractive because it needs no change in any module. Rejected because swizzling sits badly with Swift's type system, and produces invisible failure modes that are miserable to diagnose. A one-line change in thirty modules is a much better trade than a global runtime mutation.

A Bundle subclass that resolves OTA content transparently. Same appeal, same rejection, but different reason: Bundle is a class cluster, so subclassing it reliably across iOS versions is fragile and the failures are opaque.

An in-memory string dictionary as the resolution mechanism. This was seriously considered as the primary lookup path, and rejected in favour of passing a real Bundle to NSLocalizedString, for the reasons outlined in the section above. Reimplementing locale fallback chains and .stringsdict plural rules is a large amount of subtle work whose only possible outcome is parity with what the OS already does.

Locale-selective downloads. Fetching only the device's current locale would shrink downloads. It also breaks Bundle's fallback behaviour, which needs the whole locale set present, and requires detecting and reacting to locale changes. .strings files are small; the saving is not worth the correctness risk.

A single flat file per locale, merging all modules. Each module resolves through its own Bundle, so a merged file would need a new layer to route lookups back to the right module. That is a resolution mechanism we would have to build and maintain, replacing one the platform gives us for free.

Keeping the design honest

A design that depends on every module doing one small thing correctly needs enforcement. A module that calls NSLocalizedString(key, bundle: .checkout, comment:) directly does not break; it just never receives OTA updates, and nobody notices until someone publishes a fix that doesn't appear.

So we added a custom SwiftLint rule that bans bare NSLocalizedString calls across the relevant paths (App/Sources and Modules/*/Framework/Sources), excluding the localization helper files that are supposed to call it. It matches by path pattern rather than an explicit module list, so onboarding a new module requires no configuration change since the rule already covers it.

It's important for the setup to scale when new modules are added to the app. We have a tool named ModuleCreator that is responsible for the scaffolding of new modules which was extended to create the Bundle extension and both the LocalizableStrings protocol and extension allowing developers to hook into OTA without having to explicitly opt-in.

As always, observability is of paramount importance. Every cycle emits a structured log with counters. Manifest fetches and bundle downloads log at debug level with URL, HTTP status, size and duration; failures log as errors. In a best-effort system that is designed to fail quietly, logging is not a nicety but it is rather the only way to distinguish "nothing changed" from "nothing worked". For example, a cycle reporting 30 skips is healthy while a cycle reporting 30 failures looks identical from the user's side, and completely different in the dashboard.

Conclusion

The finished system is smaller than the problem it solves, which is usually the sign that the design is in the right place. A foundation module with no dependencies. Two components, one configured and injected at app level, one stateless and readable from anywhere. A minimal change per module. Static content behind a CDN, published by a CI workflow from files that have already been reviewed. A flag that removes every trace of the feature when it is off.

If you are considering something similar, the part I would emphasise is how much of the work was avoided rather than done. Locale selection, fallback chains and plural rules are handled by Bundle. Conditional requests are handled by URLSession. Atomic replacement is handled by FileManager. What remained was a manifest comparison, a careful ordering of file operations, and a zip parser we would rather not have written.

The embedded bundle is always there underneath. Every failure path in this system (no network, a corrupt archive, a 500 from the CDN, etc.) ends in the same place with the strings the app shipped with. The simple and elegant design is what makes the feature safe to run on every launch for every user.