# Low Level Design: Internationalization Service

Source: https://www.techinterview.org/post/3233471889/lld-i18n-service/
Updated: 2026-06-30 · techinterview.org

## 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-CA` → `fr` → `en`).

- 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:

- Subscribes to a `translation.published` event stream (Kafka topic).

- Groups events by `namespace + locale` and debounces for 5 seconds to batch rapid publishes.

- Reads all published translations for the affected namespace/locale from the DB read replica.

- Serializes to a compact JSON or MessagePack bundle, gzip-compressed.

- Uploads to S3 at a versioned path: `s3://i18n-bundles/{namespace}/{locale}/{hash}.json`.

- Updates a manifest file at `s3://i18n-bundles/{namespace}/manifest.json` pointing to the latest bundle hash per locale.

- 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.

See also: [Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence](https://www.techinterview.org/post/3233460274/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](https://www.techinterview.org/post/3233460275/scale-ai-interview-guide-2026-data-infrastructure-rlhf-pipelines-and-ml-engineering/)

See also: [Atlassian Interview Guide](https://www.techinterview.org/post/3233459919/atlassian-interview/)
