Low Level Design: Internationalization Service

Problem Statement

Design an internationalization (i18n) service that allows a large multi-product platform to serve user-facing strings in dozens of languages. The service must support translation storage, locale fallback chains, dynamic string loading at runtime, pluralization rules, and CDN-based delivery of translation bundles.

Requirements

Functional Requirements

  • Store and retrieve translation strings keyed by a namespace, key, and locale (e.g., payments.submit_button.fr-CA).
  • Resolve missing translations through a configurable fallback chain (e.g., fr-CAfren).
  • Support ICU MessageFormat for pluralization, gender, and select rules.
  • Serve pre-compiled translation bundles via CDN for client-side apps.
  • Allow translators to submit and review translations through an editorial workflow.
  • Support hot-reload of translations without service restarts.

Non-Functional Requirements

  • P99 read latency < 5 ms for in-process cache hits; < 20 ms for cache misses.
  • Support 500+ locales and 10 million+ translation keys.
  • 99.99% availability for read path; writes can tolerate brief unavailability.
  • Bundle generation and CDN invalidation within 60 seconds of a translation publish.

High-Level Architecture

The system separates the editorial plane (translator tooling, workflow, storage) from the delivery plane (runtime lookup, caching, CDN bundles).

Translators / CMS
       |
  Translation API (write path)
       |
  Translation DB (Postgres)
       |
  Bundle Builder (async worker)
       |
  Object Storage (S3) --> CDN (CloudFront)

  App Servers
       |
  i18n SDK (in-process LRU cache)
       |
  Translation Cache (Redis)
       |
  Translation DB (read replica)

Data Model

translation_key

CREATE TABLE translation_key (
  id          BIGSERIAL PRIMARY KEY,
  namespace   TEXT NOT NULL,
  key         TEXT NOT NULL,
  description TEXT,
  created_at  TIMESTAMPTZ DEFAULT now(),
  UNIQUE (namespace, key)
);

translation

CREATE TABLE translation (
  id          BIGSERIAL PRIMARY KEY,
  key_id      BIGINT REFERENCES translation_key(id),
  locale      TEXT NOT NULL,          -- BCP 47 tag, e.g. fr-CA
  value       TEXT NOT NULL,          -- ICU MessageFormat string
  status      TEXT NOT NULL           -- 'draft' | 'reviewed' | 'published'
              CHECK (status IN ('draft','reviewed','published')),
  version     INT NOT NULL DEFAULT 1,
  updated_at  TIMESTAMPTZ DEFAULT now(),
  UNIQUE (key_id, locale, version)
);

locale_fallback

CREATE TABLE locale_fallback (
  locale      TEXT PRIMARY KEY,
  fallback    TEXT[],   -- ordered list, e.g. {fr, en}
  updated_at  TIMESTAMPTZ DEFAULT now()
);

Core Components

1. Translation API

A REST/gRPC service exposing write operations to the editorial workflow. Key endpoints:

  • PUT /keys/{namespace}/{key} — upsert a key with description.
  • PUT /translations/{namespace}/{key}/{locale} — create or update a translation value (stored as draft).
  • POST /translations/{namespace}/{key}/{locale}/publish — advance status to published, triggers bundle invalidation.
  • GET /translations/{namespace}/{key}/{locale} — fetch current published value with fallback resolution.

2. Locale Fallback Chain

When a translation is not found for the requested locale, the service walks the configured fallback chain. The chain is loaded into memory at startup and refreshed every 60 seconds.

function resolve(namespace, key, locale):
    for each loc in [locale] + fallback_chain(locale):
        value = cache.get(namespace + : + key + : + loc)
        if value != null:
            return value
    return key   // last resort: return the raw key

Fallback chains are stored in locale_fallback and are configurable without a deploy. Example chain for pt-BR: pt-BR → pt → en.

3. In-Process LRU Cache (SDK Layer)

Each application process embeds the i18n SDK, which maintains an LRU cache of recently used translations. Cache entries are keyed by namespace:key:locale. The SDK subscribes to a Redis Pub/Sub invalidation channel; when a key is published, the service pushes an invalidation message and all SDK instances drop the affected entries within milliseconds.

class I18nCache:
    lru_cache: LRU<string, string>  // max 100k entries
    redis_sub: RedisSubscriber

    def get(ns, key, locale):
        ck = ns + : + key + : + locale
        v = lru_cache.get(ck)
        if v: return v
        v = redis.get(ck)
        if v:
            lru_cache.put(ck, v)
            return v
        v = db.query_with_fallback(ns, key, locale)
        redis.set(ck, v, ex=3600)
        lru_cache.put(ck, v)
        return v

    def on_invalidate(ck):
        lru_cache.delete(ck)
        redis.delete(ck)

4. Pluralization Engine

Translation values are stored in ICU MessageFormat syntax, which encodes plural, select, and gender rules inline:

{count, plural,
  one  {You have # unread message}
  other{You have # unread messages}
}

The SDK ships an ICU MessageFormat parser compiled to each target language (JS, Java, Go, Python). The parser receives the message template from cache and a parameter map at render time, never storing the rendered string. This keeps the cache language-agnostic and parameter-independent.

Plural rules per locale are derived from the Unicode CLDR data set, bundled as a read-only lookup table in the SDK. No network call is needed for pluralization.

5. Bundle Builder and CDN Delivery

Client-side applications (web, mobile) download pre-compiled translation bundles rather than making per-key API calls. The bundle builder is an async worker that:

  1. Subscribes to a translation.published event stream (Kafka topic).
  2. Groups events by namespace + locale and debounces for 5 seconds to batch rapid publishes.
  3. Reads all published translations for the affected namespace/locale from the DB read replica.
  4. Serializes to a compact JSON or MessagePack bundle, gzip-compressed.
  5. Uploads to S3 at a versioned path: s3://i18n-bundles/{namespace}/{locale}/{hash}.json.
  6. Updates a manifest file at s3://i18n-bundles/{namespace}/manifest.json pointing to the latest bundle hash per locale.
  7. Sends a CloudFront invalidation for /{namespace}/manifest.json.

Client apps poll the manifest on startup (and every 5 minutes) and load the bundle only when the hash changes. Bundles are immutable and long-cached (1 year Cache-Control); only the manifest has a short TTL (60 seconds).

6. Editorial Workflow

Translation strings go through three states: draft → reviewed → published. The Translation API enforces transitions. A webhook integration notifies external TMS (Translation Management Systems) like Phrase or Lokalise. Human reviewers or automated QA scripts advance strings to reviewed; a release manager publishes them. All state changes are recorded in an audit_log table for compliance.

Scaling and Reliability

Read Path Scaling

  • The in-process LRU absorbs the vast majority of reads with zero network I/O.
  • Redis acts as a shared L2 cache, sizing at ~1 GB for 10 million keys at ~100 bytes average.
  • Postgres read replicas handle cold misses; connection pooling via PgBouncer prevents connection storms on deploys.

Write Path Isolation

The editorial API is isolated from the read path. Writes go to the primary Postgres instance and enqueue a Kafka event. Even if the editorial API is down, the read path continues unaffected.

Cache Warming

On new service instance startup, the SDK loads a full namespace bundle from the CDN (same bundle used by clients) into the local LRU cache. This avoids a thundering herd of DB lookups when deploying new instances.

Consistency Trade-offs

The system is eventually consistent by design. A translation publish is visible to all SDK instances within a few hundred milliseconds via Redis Pub/Sub invalidation. This is acceptable for UI strings; safety-critical strings (legal disclaimers) should use a synchronous flush API that waits for all cache invalidation ACKs before returning.

Key Interview Discussion Points

  • Why not store rendered strings? Storing rendered (parameter-substituted) strings would multiply storage by the cardinality of all parameter combinations. Keeping ICU templates in cache and rendering at read time keeps storage O(keys * locales) instead of O(keys * locales * parameter combinations).
  • Fallback chain vs. machine translation fallback: Some teams use MT as the last fallback before the key. This requires an MT API integration in the SDK and careful latency budgeting — acceptable for editorial previews, not for production reads.
  • Bundle granularity: Per-namespace bundles balance granularity and bundle size. A single global bundle would be too large for client download; per-key API calls would be too chatty.
  • Hot reload vs. service restart: Redis Pub/Sub invalidation enables hot reload. An alternative is a polling interval (e.g., 30 seconds), which is simpler but introduces a visibility delay window.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What is an internationalization service and what are its core components?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “An internationalization (i18n) service centralizes the storage, retrieval, and delivery of translated strings and locale-specific resources so that applications can render content in any supported language without code changes. Core components include a translation store (database or object storage holding key-value pairs per locale), a bundle compiler that packages translations into efficient delivery formats, a locale resolver that maps a user’s preferences to an available locale, a fallback chain engine that degrades gracefully when a key is missing in the target locale, a pluralization rules engine that applies language-specific plural forms, and a CDN or edge cache layer that serves compiled bundles close to clients.”
}
},
{
“@type”: “Question”,
“name”: “How does locale fallback chain resolution work in an i18n service?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “When a translation key is requested for a specific locale such as pt-BR, the i18n service first looks for the key in the exact locale. If not found, it strips the region tag and tries the base language locale pt. If still missing, it falls back to the configured default locale, typically en. This chain is pre-computed at bundle-build time for static assets so no runtime lookups are needed per key. For dynamic or server-side resolution the service walks the chain in order and returns the first match, logging missing keys so translators can fill gaps. The fallback chain is configurable per product to handle cases like zh-Hant falling back to zh-Hant-TW before zh.”
}
},
{
“@type”: “Question”,
“name”: “How does an i18n service handle pluralization rules across different languages?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Different languages have different plural categories defined by Unicode CLDR (Common Locale Data Repository). English has two forms (one, other) while Russian has four (one, few, many, other) and Arabic has six. The i18n service embeds CLDR plural rules per locale and exposes a select function that takes a numeric value and returns the correct plural category string. Translators supply a translation for each required category. At runtime the service evaluates the CLDR rule for the active locale with the given count and retrieves the matching translation variant. The rules are compiled into fast decision trees at build time to avoid regex evaluation on every call.”
}
},
{
“@type”: “Question”,
“name”: “How are translation bundles delivered efficiently to client applications?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Translation bundles are compiled from the raw key-value store into locale-specific JSON or binary files and published to a CDN. Bundles are split by product surface or namespace so clients download only the translations they need. Each bundle filename includes a content hash, enabling aggressive long-term caching with immutable Cache-Control headers. On app start the client fetches the bundle for its resolved locale; if the bundle is already cached locally (localStorage or service worker cache) the network request is skipped. For server-rendered applications bundles are loaded at server startup and kept in memory. Incremental bundle updates push only changed keys, and a background sync mechanism refreshes bundles without blocking the user.”
}
}
]
}

See also: Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence

See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering

See also: Atlassian Interview Guide

Scroll to Top