Skip to content
October 12, 202612 min readGuides

Update Translations Without Redeploying: Runtime Loading, Caching, and Why Users See Stale Strings

To update translations without redeploying, move them out of the build: the app loads its translation files at runtime from a URL (your own server, an object storage bucket, or a translation CDN), so changing text means changing a file at that URL instead of shipping a new release. With i18next, that is a backend plugin instead of bundled resources. How quickly users see a change is then decided by caching: every browser cache, service worker, localStorage layer and CDN in the path may keep serving the old file until its own expiry.

This post covers the three ways teams handle translation updates today, the caches behind "I deployed and the text did not change", where bundling is still the better choice, and how it works in Locize.

Key facts
  • Bundled translations behave like code: if translation JSON is imported into the bundle or passed to i18next as resources, every text change needs a build and a deploy, or an app store release.
  • Runtime loading decouples text from releases: i18next loads namespaces through a backend plugin, and i18next-http-backend fetches them from any URL you configure.
  • Stale strings are almost always a cache: the browser HTTP cache (Cache-Control max-age), service worker caches (which never expire on their own), localStorage cache backends (7 days by default in i18next-localstorage-backend), and CDN edges.
  • Bundled resources switch the backend off: i18next only loads through a backend when no resources are passed on init, or when partialBundledLanguages is enabled.
  • Bundling is still right for some apps: offline-first apps, prerendered pages, native app binaries, and confidential strings.

Why changing text usually means a release

Most apps start with translations in the repository, next to the code, imported into the bundle and handed to i18next on init:

import i18next from 'i18next'
import en from './locales/en/common.json'
import de from './locales/de/common.json'

i18next.init({
  lng: 'en',
  fallbackLng: 'en',
  defaultNS: 'common',
  resources: {
    en: { common: en },
    de: { common: de }
  }
})

This is simple, fast and works offline. It also means the bundler treats translations exactly like code: they end up inside a JavaScript file, and the only way to change a single word is a new build. A translator fixes a typo, a developer pulls the file, opens a pull request, waits for CI and deploys. On native mobile, the cycle adds an app store review. The release cycle of your text is welded to the release cycle of your code, which is the opposite of what continuous localization needs.

The same config hides a second trap. According to the i18next configuration options, resources are meant for setups without a backend, and partialBundledLanguages: true is what allows bundled resources and a backend side by side. Teams who add a backend plugin later and leave the bundled resources in place get a backend that is never called, one of the classic reasons for "translations not updating after deploy". Even with partialBundledLanguages, a language and namespace that is already in the store from the bundle is not fetched again unless you explicitly reload it with i18next.reloadResources().

How teams handle it today

1. Bundled files plus a redeploy

The default. Translation files live in the repo, a script or CI job pulls new translations from wherever translators work, and the next deploy ships them. The upside: no runtime request, offline support from the first launch, and text versioned together with the code. The cost: every fix waits for a release, and the release cadence of your slowest platform becomes the update cadence of your text. How this handoff collides with continuous deployment is covered in how batch translation workflows break continuous deployment.

2. Runtime loading from your own server or object storage

The app fetches its translation files at startup from a URL you control: a static folder on your web server, a storage bucket, any host. With i18next, that is i18next-http-backend:

import i18next from 'i18next'
import HttpBackend from 'i18next-http-backend'

i18next
  .use(HttpBackend)
  .init({
    fallbackLng: 'en',
    ns: ['common'],
    defaultNS: 'common',
    backend: {
      // any URL: your web server, a storage bucket, a CDN
      loadPath: 'https://translations.example.com/{{lng}}/{{ns}}.json'
    }
  })

Components in React, Vue or React Native keep calling t() as before; only the source of the strings changes. A text change is now an upload, with no build involved. What you take on in return: the pipeline that copies translations from where translators work to that host, cross-origin headers, the availability of that host, and the cache headers on those files, which the next section is about.

3. A CDN fed by your translation management system

The translation management system (TMS) publishes the files to a CDN itself, and the app loads them from there. The download, commit and upload steps disappear from your pipeline: a saved and published translation is the live file. You still pick a backend plugin and you still live with caching, but hosting, global distribution and the publish step are not yours to run. How this fits a growing i18next setup is described in i18next in production.

Bundled + redeployOwn server or storageTMS-backed CDN
A text change needsBuild and deploy (or store release)A file uploadA publish in the TMS
Renders offline on first launchYesOnly with a bundled fallbackOnly with a bundled fallback
You operateNothing extraHosting, upload pipeline, cache headersBackend config and cache settings
Where stale text comes fromOld builds still runningCaches between host and userCaches between CDN and user

How caching decides when users see the change

Once translations load at runtime, the question is no longer "did we deploy?" but "which cache still holds the old file?". HTTP caching is controlled by the Cache-Control response header:

  • max-age=N: the response stays fresh for N seconds after it was generated; while fresh, a cache may reuse it without asking the server.
  • no-cache: caches may store the response, but must validate it with the server before every reuse. If the file has not changed, that is a cheap conditional request.
  • no-store: no cache may store the response at all.
  • s-maxage: a separate freshness lifetime for shared caches such as CDNs; it overrides max-age there and is ignored by the browser.
  • immutable: the response will not change while fresh, so the browser skips revalidation.

Hashed file names: perfect for bundles, awkward for runtime text

For static assets, MDN describes the cache-busting pattern: put a version or hash into the URL, never modify the file, cache it for a year and mark it immutable. That is how bundlers ship JavaScript, and it is why bundled translations are never stale once the new bundle loads.

For translations loaded at runtime, it cuts the other way. If the file name contains a hash, the app has to learn the new name, and the place it learns it from is usually the next deploy. The same applies to i18next-http-backend's queryStringParams option (for example { v: '1.3.5' }): the version string lives in your code. Runtime translations therefore usually sit at stable URLs with a short max-age, or with no-cache and revalidation, trading a few more requests for freshness. If you want long caching anyway, have the app fetch a small, uncached manifest first that names the current translation version.

The layers between a change and the user

LayerWhat holds the old textHow to get the new text through
Browser HTTP cacheThe JSON response, for its max-ageA short max-age, or no-cache with revalidation. i18next-http-backend hands requestOptions to fetch, and cache: 'no-cache' there makes the browser revalidate even a fresh entry (a CDN in front may still answer from its own cache)
Service workerEntries in the Cache API, which are only updated when your code updates them and do not expire on their ownKeep translation URLs out of precaching, or fetch them network-first
localStorage cachei18next-localstorage-backend serves a cached namespace until expirationTime (default: 7 days)A shorter expiration, a new defaultVersion or versions value, or the chained backend's refresh mode (below)
CDN edgeThe edge copy, for its max-age, s-maxage or the CDN's own TTLA short TTL, or a purge on publish
Long-running serverThe i18next instance that loaded the files at startupi18next-http-backend's reloadInterval: one hour by default outside the browser, off in the browser. Reuse one instance per process
Prerendered HTMLText rendered into the HTML at build timeA rebuild, or revalidation such as Next.js ISR
React renderingNothing: the store has the new text, but no component re-renderedreact-i18next re-renders on languageChanged by default; set bindI18nStore: 'added' to re-render when resources are added

A setup that renders instantly from a local cache and still picks up changes on the same visit combines i18next-chained-backend with its refreshAndUpdateStore mode, which serves the cached copy, fetches the fresh one from the next backend, updates the cache and adds it to the store:

import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'
import ChainedBackend from 'i18next-chained-backend'
import LocalStorageBackend from 'i18next-localstorage-backend'
import HttpBackend from 'i18next-http-backend'

i18next
  .use(ChainedBackend)
  .use(initReactI18next)
  .init({
    fallbackLng: 'en',
    backend: {
      backends: [LocalStorageBackend, HttpBackend],
      backendOptions: [
        { expirationTime: 7 * 24 * 60 * 60 * 1000 }, // the default: 7 days
        { loadPath: 'https://translations.example.com/{{lng}}/{{ns}}.json' }
      ],
      // serve the cached copy first, then fetch the fresh one and update the store
      cacheHitMode: 'refreshAndUpdateStore'
    },
    react: {
      bindI18nStore: 'added' // re-render when refreshed resources land in the store
    }
  })

For apps that stay open for hours (dashboards, admin tools), call i18next.reloadResources() on an interval or when the tab becomes visible again; with bindI18nStore: 'added', the reloaded text shows up without a page reload.

Debugging "translations not updating"

  1. Is the translation file requested at all? Check the network tab. No request means the strings come from the bundle (look for resources on init), from a service worker, or from localStorage.
  2. Is it served from a cache? "Memory cache" or "disk cache" means the browser served its own copy without asking. A 304 means the browser revalidated and the CDN or origin answered "unchanged", so a stale text after a 304 sits upstream. Check the Cache-Control and Age response headers to see what the CDN is doing.
  3. Is the file itself current? Open the file URL directly and compare it with the source. If it is old there, the problem is the publish or upload step, not the app.
  4. What does the browser store? In the developer tools, look at localStorage entries with the i18next_res_ prefix (the default of i18next-localstorage-backend) and at Cache Storage.
  5. On the server: was the process restarted, and does the i18next instance reload on an interval? A new instance per request downloads the translations on every request, and each instance keeps its own reload timer running (reloadInterval defaults to one hour outside the browser), so create the instance once per process and reuse it.
  6. The store has it, the screen does not: that is a render binding, not a cache. See bindI18nStore above.

If the symptom is keys or fallback text rather than old text, the cause is a missing translation, not a stale one: see missing translations.

When bundling is the right choice

Runtime loading is not automatically better. Bundle when:

  • The app is offline-first. A runtime-loaded translation needs a network on first launch. If the app must render before it has ever been online, bundle at least the default language. The hybrid is a chained backend with the network first and bundled resources as the fallback.
  • Pages are prerendered (SSG, static export). Text rendered at build time is part of the HTML. Replacing a file on a CDN does not change HTML that already exists; the page needs a rebuild or revalidation (Next.js ISR, which a static export does not support). Loading fresher text on the client after hydration means users can see the text change after the page appears. For content pages that rebuild on every change anyway, bundling is simpler.
  • Strings ship inside a native app binary. iOS and Android string resources are compiled into the app, so changing them means a new build. Apple's App Review Guidelines rule out downloading code that changes an app's features (2.5.2). Translation strings are data, not code, which is why React Native apps can load them at runtime. But guideline 2.3.1 also asks that the app's functionality be clear to App Review, so if your strings describe features, prices or terms, shipping them with the reviewed build keeps the two identical.
  • Strings are confidential. A file on a public URL can be read by anyone who finds it: unreleased product names, internal tooling, customer-specific wording. Bundle, or self-host behind authentication. A key that ships inside a browser app is visible to every user, so key-protected downloads protect server-side and build-time fetches, not a public frontend.
  • Text must change atomically with code. A new key used by new code has to exist before that code reaches users. With runtime loading, publish new keys before or together with the deploy, otherwise users see fallbacks.

How this works in Locize

Locize, built by the team behind i18next, is the third option above:

  • Published to a CDN by default. Translations are published to the Locize CDN unless you turn this off, and web and React Native apps load them at runtime through i18next, so a published change needs no redeploy:
import i18next from 'i18next'
import LocizeBackend from 'i18next-locize-backend'

i18next
  .use(LocizeBackend)
  .init({
    fallbackLng: 'en',
    backend: {
      projectId: '[PROJECTID]',
      version: 'latest', // a dedicated production version needs a plan with several versions
      cdnType: 'standard' // or 'pro', matching your project's CDN type
    }
  })
  • Two CDN types, two caching models. The Standard CDN (BunnyCDN, the default for new projects) is public only, with a fixed cache duration of one hour. The Pro CDN (Amazon CloudFront, from the Growth plan) lets you set the Cache-Control max-age per version, supports private publishing that requires an API key, and keeps backups of published namespaces. See CDN types: Standard vs Pro and caching.
  • Development and production stay apart. On the Pro CDN, the caching docs recommend no cache on development versions, and a dedicated production version without auto-publish, published manually or through the API or CLI, with a low but non-zero max-age. While developing against the Standard CDN, noCache: true in i18next-locize-backend (or ?cache=no on the URL) bypasses the cache.
  • Bundling remains an option. Download translations with the CLI, the API or the locize/download GitHub Action and bundle them, or combine both with a bundled fallback. Native iOS and Android apps take this path: their string files are synced at build time, as Locize has no native over-the-air SDK. For confidential texts, disable auto-publish and bundle, or use private publishing.

To see the loop end to end: create a project, point i18next-locize-backend at it, change a string in the editor, publish, and reload your app. The bigger picture of shipping text independently of code is on the continuous localization page.

Frequently asked questions

How do I update translations without redeploying my app? Load translations at runtime from a URL (your own server, a storage bucket or a TMS-backed CDN) instead of compiling them into the build. With i18next, use a backend plugin such as i18next-http-backend and do not pass bundled resources on init. A text change becomes a file change, subject to the caches in between.

Why are my i18next translations not updating after a deploy? Usually bundled resources that switch the backend off, a cache still serving the old file (browser, service worker, localStorage backend, CDN edge), a long-running server instance that loaded the files at startup, or React components that did not re-render after the store changed.

How do I load translations from a CDN at runtime? Point an i18next backend at the CDN URL pattern, keep the URL stable, choose a max-age that matches how fresh the text needs to be, and bundle a fallback language if the app must render offline.

How long does it take until users see a changed translation? As long as the slowest cache in the path allows: the browser's max-age, the CDN's lifetime, a localStorage expiration, or a service worker entry your code has not replaced. Open apps see the change on the next load of the namespace.

When should I bundle translations instead of loading them at runtime? For offline-first apps, prerendered pages, native app binaries that go through store review, and confidential strings. A bundled fallback combined with runtime loading covers most of the middle ground.

Can I change the text of a mobile app without an app store release? With React Native and i18next, yes: translations load at runtime like on the web. Native iOS and Android string resources are compiled into the binary, so without an over-the-air SDK (Locize has none) changing them means a new build and a store release.

Tired of managing translations by hand?

Locize is the translation management backend by the i18next team: CDN delivery, AI translation, in-context editing, no redeploys.

Start your free 14-day trial