Over the air app translations let a team correct copy without waiting for another binary release. They also add a new failure path. A remote file can belong to the wrong source version, omit a key, arrive half-written, or fail to load when the user is offline. If the app trusts that file unconditionally, a small copy fix can replace a working bundle with broken UI. The safer design keeps bundled resources as the baseline, accepts only compatible remote artifacts, and makes every activation reversible. This guide defines that path from artifact creation through release verification.
Why remote translation files fail differently
Bundled localization has one useful property: the resources and the code ship together. A build can verify that required keys, placeholders, and plural structures agree before the store distributes it. Remote delivery separates those artifacts. The installed binary may be several releases behind the translation service, while the service may hold files produced from a newer source catalog.
Release delays are why teams ask for this separation. One Dart i18n issue describes a workflow that would load ARB files over HTTP, cache them, and update translations without rebuilding the app. That workflow turns a build input into runtime data. The client now needs a compatibility contract and an activation state machine.
A second problem appears at the lookup boundary. A Crowdin Android SDK user found that OTA updates worked in app-owned screens but not in a dependency-owned screen. That report does not prove every SDK has the same defect. It shows why a successful download is not enough: every UI path must use the resolver that knows about remote strings.
Keep the installed bundle as the root of trust
Do not model the downloaded file as a replacement for the app's localization bundle. Model it as a compatible overlay. The bundle remains complete enough to run the installed binary. The overlay may replace values only when it passes validation, and a missing overlay value must fall through to the bundle.
The resulting cold-start path is deterministic:
- Load bundled resources first.
- Check whether a previously activated overlay matches the installed source version and locale.
- Use that overlay if it is valid.
- Fetch a newer artifact in the background.
- Activate the new artifact only after validation and durable storage succeed.
The open-source i18nAgent iOS OTA package demonstrates this general shape with a source hash, cached startup, ETag requests, atomic writes, and bundled fallback. That repository is an implementation example, not a platform guarantee. Teams using another SDK can still apply the same state transitions.
Define a versioned artifact contract
An OTA endpoint needs to return more than a bare map of keys and values. The client must be able to decide whether a file belongs to the installed build. Put that decision data in a compact manifest:
{
"schema_version": 1,
"source_hash": "sha256-of-source-catalog",
"locale": "fr-FR",
"resource_table": "Localizable",
"revision": 184,
"generated_at": "2026-08-03T10:00:00Z",
"strings": {
"checkout.confirm": "Confirmer la commande"
}
}
source_hash binds the artifact to the source catalog used by the installed build. schema_version protects the parser from a format it does not understand. locale stops a valid French file from entering a French Canadian or another locale's cache by mistake. resource_table matters when the app has more than one catalog. revision gives operations staff a readable rollback target even when content hashes remain the stronger compatibility check.
Do not accept an artifact merely because its JSON parses. Validate the expected locale, schema, source hash, table name, maximum file size, key syntax, placeholders, and plural structures. If the transport supplies a checksum or signature, verify it before parsing values into the active resolver. Reject the complete artifact when structural checks fail. Quietly dropping individual malformed values creates a file whose actual coverage no longer matches its revision.
Fetch conditionally, then activate atomically
Repeatedly downloading every locale file wastes bandwidth and increases the number of failure opportunities. HTTP entity tags give the client a standard validator. RFC 9110 defines ETag and conditional request semantics, which let a client ask whether the selected representation changed without treating a timestamp as content identity.
Separate network access, validation, storage, and activation:
function refresh(locale, installedSourceHash):
cached = readManifest(locale, installedSourceHash)
response = fetchArtifact(locale, installedSourceHash, ifNoneMatch=cached.etag)
if response.status == 304:
return NO_CHANGE
if response.status != 200:
return KEEP_CURRENT
candidate = parse(response.body)
validate(candidate, locale, installedSourceHash)
temporaryPath = writeTemporary(candidate)
fsync(temporaryPath)
atomicallyReplace(activePath(locale, installedSourceHash), temporaryPath)
saveMetadata(response.etag, candidate.revision)
notifyResolver(candidate.revision)
return ACTIVATED
A failed request leaves the current state alone. So does a parse or validation error. Write the candidate to a temporary path, then replace the active file only after the write completes. Otherwise, a terminated process or full disk can leave a partial file active. Keep the prior valid artifact until replacement succeeds. Retain a bounded last-known-good revision when fast rollback matters.
Activation timing is a product decision. Applying a new overlay during a visible screen can mix two revisions in one flow. Many apps should activate at the next screen boundary or next launch, then record which revision served the session. A settings screen that offers a manual refresh can explain that new text appears after reopening the affected view.
Resolve every key through one fallback chain
On iOS, Apple's Bundle.localizedString documentation describes the standard bundle lookup entry point. An iOS overlay must preserve the caller's key, value, and table behavior rather than flattening every resource into one dictionary. On Android or Flutter, the concrete resolver differs, but the ownership rule remains the same: app code and shared UI components need one documented path for user-facing strings.
Use a lookup chain with explicit outcomes:
remote overlay for exact locale and source hash
bundled resource for exact locale
bundled platform fallback
visible development sentinel in non-production builds
Production should not display the development sentinel. It should reach a usable bundled value. Tests, however, need a loud signal when both overlay and bundle miss a required key. That distinction prevents a fallback from hiding incomplete coverage during development while keeping the released app usable. The same reasoning applies to the bundled layer on its own, covered in preventing missing translations with fallback languages.
Dependency-owned UI needs special treatment. If a library resolves its own embedded resources, an app-level overlay may never see those calls. Decide whether the library remains bundle-only, accepts a resolver through dependency injection, or exposes its own resource update contract. Do not promise OTA coverage until an integration test proves the actual dependency screen changes.
Keep remote resources inside the app's policy boundary
OTA translations must remain data rather than executable behavior. Do not put scripts, expressions, class names, navigation destinations, or feature rules in the artifact. A message-format grammar may need structured placeholders and plurals. Even then, the client should parse a fixed, bounded grammar instead of executing arbitrary content.
Apple's App Review Guidelines section 2.5.2 says apps may not download code that introduces or changes app functionality. The guideline does not certify a particular OTA localization design. It sets the boundary the design must respect. Document the downloaded format, keep the bundle functional without it, and include the behavior in review notes when appropriate. If the payload starts controlling product logic, move that logic back into reviewed code or a separately governed configuration system.
Treat the endpoint as a production dependency. Use TLS, authenticate publishing operations, separate public read credentials from translation-management credentials, and prevent a client from selecting another tenant's namespace. The app should send only the identifiers needed to choose a locale artifact. Translation files should not contain secrets or user-specific data.
Build rollback into the cache model
Deleting the latest server artifact does not remove copies already active on devices. Rollback therefore needs an explicit action the client can observe. Publish a corrected revision, mark the bad revision as revoked, or change the compatibility record so clients reject it.
The client should store the active revision and the previous known-good revision. When the service revokes the active one, the next successful check can switch to the prior file or remove the overlay and return to bundled strings. Put an expiry policy on cached artifacts, but do not make expiry force a blank state. Expiry should trigger a refresh and then fall back to the bundle if the network remains unavailable.
Rollback telemetry should answer practical questions: which app version, locale, source hash, and overlay revision were active when a lookup or validation failed? Record counts rather than translated user content. The data should let an operator distinguish a corrupt artifact from a resolver path that bypassed the overlay.
Verify the full release matrix
A parser unit test covers one layer. Before broad rollout, run the same artifact through real app states.
| Scenario | Expected result |
|---|---|
| First launch without network | Bundled strings render with no startup delay |
| Valid cached overlay, no network | Cached values render and missing keys use the bundle |
| HTTP 304 response | Active revision and files remain unchanged |
| New valid revision | Candidate validates, writes atomically, and activates at the chosen boundary |
| Wrong source hash | Candidate is rejected and the current valid state remains active |
| Truncated or malformed file | Candidate is rejected before replacement |
| App binary upgrade | Old incompatible cache is ignored and bundled strings remain usable |
| Revoked revision | Client returns to the prior known-good file or bundled strings |
| Dependency-owned screen | Test proves whether that component uses the OTA resolver |
| Locale switch | Cache and lookup select the exact new locale without leaking prior values |
Add placeholder and plural cases for every supported file format. Test process termination during the temporary write and during metadata persistence. On iOS, include SwiftUI, UIKit, storyboards, and any framework-owned UI that the product claims to cover. On Android, include activities, Compose, services that create notifications, and library resources where relevant.
Start rollout with internal builds and one noncritical locale. Watch validation failures, fallback rates, refresh latency, and active revision distribution. Expand only after the app proves that an unavailable service changes no user-visible behavior beyond leaving the prior translations in place.
Mistakes that turn copy updates into incidents
The first mistake is using locale plus filename as the complete cache key. A new binary can then activate an artifact generated for an older or newer source catalog. Include the source hash or another build-compatible resource version.
The second is replacing the active file before validation finishes. Parsing after replacement converts bad input into current state. Validate a temporary candidate, then switch paths atomically.
The third is treating remote coverage as complete. Even a reviewed file can omit a key introduced by the installed binary. Resolve each missing value from the bundle instead of returning an empty string.
The fourth is testing only app-owned screens. The reported Crowdin dependency-boundary failure is a reminder to exercise real library UI, widgets, notifications, and extensions. A green network log does not prove the displayed copy uses the downloaded resource.
The fifth is turning translations into remote code. Keep behavior in the binary and keep the payload limited to validated message data. This simplifies security review, fallback, and compliance with store rules.
Next action
Start with one locale and one resource table. Before integrating an SDK, write down the artifact manifest, compatibility check, cache key, activation boundary, and rollback response. Automate the ten release-matrix scenarios against a build that can run entirely from bundled strings. Ship OTA delivery only after every rejected or unavailable artifact leaves that build usable.
References
- Apple App Review Guidelines, section 2.5.2 supports the boundary between downloaded data and prohibited downloaded code that changes app functionality.
- Apple Bundle localized string lookup identifies the standard iOS lookup behavior an overlay must preserve.
- RFC 9110 ETag semantics defines the entity-tag validator used for conditional retrieval.
- i18nAgent iOS OTA translation package is an inspectable implementation example for source hashes, cached activation, ETags, atomic writes, and bundled fallback.
- Dart i18n issue 559 records a practitioner request to load and cache ARB translations over HTTP without tying updates to app releases.
- Crowdin Android SDK issue 253 records an OTA update that did not affect a dependency-owned screen.
