# Design a URL Shortener (TinyURL / bit.ly)

> A classic FAANG warmup. Generate short codes, store them, redirect fast, scale to billions of URLs.

Canonical: https://ironclad.academy/system-design/articles/design-url-shortener | Difficulty: beginner | Topics: Interview, Hashing, Caching, Sharding
Asked at: Google, Amazon, Microsoft

## Key takeaways
- Counter plus base62 with per-shard ID ranges gives collision-free code generation with zero per-request coordination overhead.
- The CDN-to-Redis-to-DB read path ensures more than 99% of redirects never touch the database, keeping p50 redirect latency around 1ms.
- Use a 302 without cache headers, not a 301, so browsers don't cache the mapping and clicks that reach your infrastructure stay observable; bit.ly uses 301 and accepts some repeat-click blindness.
- Push click events to Kafka fire-and-forget and batch-flush counters to avoid write contention on hot codes that can spike to thousands of clicks per second.
- At 6 billion URLs over 5 years the dataset is only about 3 TB — storage is not the hard problem; the hard problems are collision-free ID generation and caching.

> **In one line:** Generate a unique short code per long URL, store the mapping, and serve redirects from cache at the edge — a read-heavy key-value lookup that lives or dies on caching and collision-free ID generation.

**The problem.** Turn a long URL into a 6–7 character code and redirect billions of clicks to the original, fast and forever. The interesting tension is that it's overwhelmingly **read-heavy** (100:1 to 1000:1) and a single lost mapping breaks links embedded in years-old emails.

**Requirements that drive the design.**
- Create a short code; redirect on `GET /:code`.
- p99 redirect latency < 100 ms globally; very high availability and durability.
- Optional: custom aliases, expiry, per-click analytics.

**Scale (back-of-the-envelope).**
- ~40 writes/s avg (~120 peak); ~4k reads/s avg (~12k peak).
- ~6 B URLs over 5 years × ~500 B ≈ **3 TB** — fits sharded Postgres or DynamoDB.
- Working set is Zipfian: ~2 M hot codes ≈ 1 GB → fits one Redis node.

**The architecture in one diagram.**

```mermaid
flowchart LR
    U[Client] --> CDN[CDN edge]
    CDN -->|miss| API[Shortener API]
    API -->|redirect| CACHE[(Redis<br/>code → long_url)]
    CACHE -. miss .-> DB[(Sharded URL store)]
    API -->|shorten| IDGEN[ID generator<br/>per-shard ranges]
    IDGEN --> DB
    API -.click event.-> KAFKA[Kafka → analytics]
    style CDN fill:#ff6b1a,color:#0a0a0f
    style CACHE fill:#15803d,color:#fff
    style DB fill:#0e7490,color:#fff
```

**Key design decisions.**

| Decision | Choice | Why |
| --- | --- | --- |
| Short code generation | Counter + base62, **per-shard ID ranges** | No collisions, no global coordination on the hot path |
| Storage | Sharded by `code` (Postgres/DynamoDB) | 3 TB + 12k QPS shards cleanly; point lookups only |
| Read path | CDN → Redis → DB, write-back on miss | >99% of reads never touch the DB; ~1 ms p50 |
| Redirect status | **302**, no cache headers (vs. 301) | Browsers don't cache the mapping, so clicks stay observable; bit.ly uses 301 and accepts repeat-click blindness |
| Analytics | Fire-and-forget to Kafka, batch counters | Keeps redirects at ~1 ms; no write contention on hot codes |
| Abuse / safety | Rate-limit creates + Safe Browsing check | Stops spam and malicious-link laundering |

**If you have 60 seconds, say this.** "It's a read-heavy KV lookup. I generate collision-free codes with a counter in base62, handing each shard its own ID range so there's no global bottleneck. I shard the store by code, and put a CDN + Redis in front so >99% of redirects never reach the database. I use 302s so click analytics are accurate, and I push those clicks through Kafka to a counter worker so the redirect path stays ~1 ms. To scale 10×: more shards, multi-region async replication, and async writes."



## The problem

bit.ly and TinyURL turn a long, unwieldy URL into a handful of characters — `https://bit.ly/3xQpR7` instead of a 200-character Google Docs link. You paste the short code into a tweet, an email, or a billboard, and when someone clicks it, they land on the original page within milliseconds. The whole interaction takes under 100ms, and the mapping has to hold up years later when someone digs a link out of their inbox.

Underneath that simplicity is a classic engineering trade-off: the create path is rare (maybe 40 writes per second), while the redirect path is relentless (thousands of reads per second, spiking to tens of thousands when a link goes viral). That 100:1 or 1000:1 read-to-write ratio means the read path must be radically cheaper than a round-trip to the database. A single lost mapping breaks a link permanently — there is no "link not found, try again later." This combination of extreme read skew, zero-loss durability, and sub-100ms global latency is what makes the URL shortener a staple of system design interviews.

The two hardest sub-problems are code generation (how do you mint millions of unique 6-character codes without coordination overhead or collision risk?) and caching (how do you keep the most popular codes available at the edge so the database never sees the load?). Get those right, and the rest of the design follows.

## Functional requirements

- `POST /shorten { long_url }` → returns a short code (e.g. `https://sysf.ge/aZx4Q3`).
- `GET /:code` → 302 redirect to the long URL (typically 302; see trade-offs below).
- (Optional) Custom aliases: `/shorten { long_url, alias: "promo" }`.
- (Optional) Analytics: click counts per short URL.
- (Optional) Expiration: short URLs that expire after N days.

## Non-functional requirements

- **Read-heavy**: redirects vastly outnumber creations. Often 100:1 or 1000:1.
- **Low redirect latency** — < 100ms p99 globally.
- **High availability** — a 503 from a URL shortener breaks every link in every email.
- **Durable** — losing a short→long mapping breaks the internet.

## Capacity estimation

| Dimension | Estimate | How we got there |
| --- | --- | --- |
| Write rate (avg) | ~40 writes/s | `100M ÷ (30 × 86,400)` |
| Write rate (peak) | ~120 writes/s | ~3× average burst |
| Read rate (avg) | ~4,000 reads/s | 100:1 read-to-write ratio |
| Read rate (peak) | ~12,000 reads/s | ~3× average burst |
| Row size | ~500 bytes | long_url + short_code + metadata |
| URL corpus (5 yr) | 6B URLs | `5 years × 100M/month × 12 months` |
| Storage | ~3 TB | `6B × 500B` |
| Read bandwidth | ~2 MB/s | `4k req/s × 500B` — tiny |
| Hot working set | ~1 GB | `~2M hot codes × 500B`; Zipfian distribution means a few million codes serve most clicks |

**Takeaway:** 3 TB and 12k QPS is well within reach of a sharded Postgres or DynamoDB.

## Building up to the design

Jumping straight to "sharded Postgres + Redis + CDN + Kafka" sounds confident but skips the reasoning. In an interview (and in real life), the better move is to start with the simplest thing that could possibly work and grow it as each layer breaks. Each step below is a complete, runnable system — and each one fails for a specific, nameable reason.

### V1: A Python dict on one box

```python
urls = {}                              # in-memory map

def shorten(long_url):
    code = random_base62(6)
    urls[code] = long_url
    return code

def lookup(code):
    return urls.get(code)
```

One Flask process, one machine, no database. Handles a few thousand requests/sec from your laptop and takes you all the way to a working demo. The problem is simple: restart the process and every URL you ever shortened is gone. Anything past "demo" needs durability.

### V2: Add Postgres

Replace the dict with a `urls(code, long_url)` table. The code is now ~30 lines including connection setup. You get durability, multi-process scaling, and real query support — who created what, when. But every redirect is a DB query. At ~4k reads/sec, your single Postgres is fine on CPU, but a cold lookup is 5–20ms. That stacks badly when you're targeting sub-100ms p99 globally with every hop counted.

### V3: Put Redis in front

```
GET code → Redis hit?  → return long_url
                ↓ miss
           Postgres → populate Redis → return
```

Redis holds ~2M hot codes. Because the working set is Zipfian, more than 99% of reads never touch Postgres — p50 drops to ~1ms and DB load falls by 100×. The new weak point is availability: still a single API box. One unlucky deploy or kernel panic, and every short URL in existence goes dark, including ones embedded in years-old emails.

### V4: Two API boxes behind a load balancer

Same DB, same Redis, but now 2+ stateless API servers behind an ALB or HAProxy, with a read replica for DB reads. This removes the API SPOF and handles 10k+ QPS comfortably. What surfaces next is ID generation: if every server calls `random_base62(6)` independently, you'll eventually hit collisions and have to retry on a `UNIQUE` violation. Manageable, but the failure mode is "one user's `POST /shorten` randomly takes 200ms because you hit 3 collisions in a row."

### V5: A dedicated ID generator + sharded storage

This is the production design. Each ID-generator instance pre-claims a counter block from a central counter service — one gets IDs `0..1M`, the next `1M..2M`, and so on — and encodes each ID to base62 with zero coordination and zero collisions on the hot path. Storage is a separate concern: each new row is a single-shard write to whichever of the N Postgres instances `hash(code)` selects. Minting an ID and placing its row are decoupled, so there's still no cross-shard coordination.

This is where the full architecture diagram picks up. Everything after this section is naming the pieces and sharpening the trade-offs.

### V6 (optional, for "scale 10×" follow-ups)

Put a CDN in front of the API so most redirects are served at the edge and never reach origin. Move click analytics into Kafka so the redirect path stays 1ms regardless of how hot a code is. Add multi-region async replication — a brand-new code is fine to lag by 1–2 seconds globally, because the user shares the link before anyone else can click it.

```mermaid
flowchart LR
    V1["V1: dict in memory<br/>~1k QPS, no durability"] --> V2["V2: + Postgres<br/>durable, slow reads"]
    V2 --> V3["V3: + Redis<br/>1ms reads, single SPOF"]
    V3 --> V4["V4: + LB, replicas<br/>10k QPS, write bottleneck"]
    V4 --> V5["V5: sharded DB + ID gen<br/>100k+ QPS"]
    V5 --> V6["V6: + CDN + Kafka + multi-region<br/>global scale"]
    style V1 fill:#0e7490,color:#fff
    style V3 fill:#15803d,color:#fff
    style V5 fill:#ff6b1a,color:#0a0a0f
    style V6 fill:#a855f7,color:#fff
```

The rest of this article zooms in on V5 — the design you'd actually run in production.

## API design

```http
POST /api/v1/shorten HTTP/1.1
Authorization: Bearer ...
Content-Type: application/json

{ "long_url": "https://example.com/some/very/long/path",
  "alias": "promo",          // optional
  "expires_in_days": 30 }     // optional

→ 201 Created
{ "short_url": "https://sysf.ge/promo",
  "code": "promo",
  "expires_at": "2026-03-07T..." }
```

```http
GET /:code  →  302 Found
Location: https://example.com/some/very/long/path
Cache-Control: private, max-age=0
Surrogate-Control: max-age=86400
```

`max-age=0` makes the response immediately stale, so the browser revalidates (re-requests) on every click instead of reusing a cached redirect — that's what keeps browser-side clicks observable. `private` keeps *shared* caches from storing the response, which is why the CDN needs the separate `Surrogate-Control` override (or an equivalent CDN-specific header such as `CDN-Cache-Control`) to cache at the edge for up to a day — that edge cache is what delivers the 90%+ CDN hit rate in the caching table. Clicks served from the edge never reach origin, so you recover them from CDN edge logs shipped into the same Kafka pipeline, not from the redirect handler. (See the [301 vs 302 trade-off below](#301-vs-302).)

## Generating the short code

The question underneath code generation is: how do you produce a short, unique string without either (a) a global coordination bottleneck, or (b) relying on luck and retries? There are three realistic approaches.

### Hash (e.g. base62 of MD5)

```python
hash = md5(long_url + salt)
code = base62(hash[:6])    # first 6 chars
```

Stateless and simple — no shared state at all. The catch is collisions: two different long URLs can hash to the same 6-char prefix, so you need a retry loop with a different salt. For small scale this is fine. At billions of URLs it becomes a slow, annoying edge case.

### Counter + base62

A central counter generates increasing IDs (1, 2, 3, …). Convert to base62.

```
ID 125    → code "21"     (2 chars)
ID 1M     → code "4c92"   (4 chars)
ID 56B    → code "Z7Qias" (6 chars: 62^6 ≈ 56B)
```

No collisions ever. The problem is that a truly global counter is a write bottleneck — it's a single point of serialization. The fix is per-generator counter blocks: each ID-generator instance claims a block of IDs — `0..1M`, `1M..2M`, and so on — and mints codes from it with no coordination. When an instance exhausts its block, it claims another. The claim operation is a simple atomic increment on a counter service and happens at most once per million inserts — essentially free.

One caveat that a strong candidate raises unprompted: sequential IDs make codes predictable. Anyone can walk `aaaaaa`, `aaaaab`, `aaaaac`, ... and scrape every URL ever shortened — the *Gone in Six Characters* research did exactly this and turned up private documents — and the raw code value leaks your creation volume to competitors. The cheap fix is a bijective permutation before encoding: run the counter value through a keyed Feistel network (or multiply by an odd constant mod `62^n`) so consecutive IDs scatter across the keyspace. Collision-freedom is preserved; enumeration and volume leakage are not.

```mermaid
flowchart LR
    CS[Counter Service<br/>global atomic counter] -->|"claims range 0..1M"| S0[ID gen 0<br/>mints locally]
    CS -->|"claims range 1M..2M"| S1[ID gen 1<br/>mints locally]
    CS -->|"claims range 2M..3M"| S2[ID gen 2<br/>mints locally]
    S0 -->|"encode to base62"| C0["code: '0'...'4c91'"]
    S1 -->|"encode to base62"| C1["code: '4c92'...'8oi4'"]
    style CS fill:#ff6b1a,color:#0a0a0f
    style S0 fill:#15803d,color:#fff
    style S1 fill:#15803d,color:#fff
    style S2 fill:#15803d,color:#fff
```

This is the recommended approach — no collisions, no per-request coordination, and the base62 encoding keeps codes short.

### Pre-generated pool

Generate 100M random codes ahead of time, store them in an "unused codes" table. Each `POST /shorten` claims one and moves it to a "used" table. No collisions, fast claims. The downside is operational overhead: you need to regenerate the pool before it empties, and the atomic claim across two tables adds a step. Workable, but counter + base62 with sharded ranges is simpler to reason about.

## Schema

A single Postgres table is plenty until you're at billions of rows.

```sql
CREATE TABLE urls (
  code        VARCHAR(8)  PRIMARY KEY,
  long_url    TEXT        NOT NULL,
  user_id     BIGINT,
  created_at  TIMESTAMPTZ DEFAULT now(),
  expires_at  TIMESTAMPTZ,
  click_count BIGINT      DEFAULT 0
);

CREATE INDEX urls_user_id ON urls(user_id);
CREATE INDEX urls_expires ON urls(expires_at) WHERE expires_at IS NOT NULL;
```

Past 1 TB, shard by `code` (consistent hashing). Each shard is a Postgres instance.

## Architecture

```mermaid
flowchart TD
    U[User] --> CDN[CDN]
    CDN --> LB[Load Balancer]
    LB --> API[Shortener API]
    API --> SVC{Operation}

    SVC -->|"shorten"| IDGEN[ID Generator<br/>per-shard ranges]
    IDGEN --> DB[(Sharded URLs DB)]

    SVC -->|"redirect"| CACHE[(Redis<br/>code → long_url)]
    CACHE -. miss .-> DB

    DB --> CACHE
    API --> KAFKA[Kafka:<br/>click events]
    KAFKA --> COUNT[Click Counter]
    COUNT --> ANA[(Analytics DB)]

    style CDN fill:#ff6b1a,color:#0a0a0f
    style CACHE fill:#15803d,color:#fff
    style DB fill:#0e7490,color:#fff
    style KAFKA fill:#a855f7,color:#fff
```

The redirect path (the hot path) flows like this: a browser hits `/aZx4Q3`, the CDN checks its own cache, and if it has the answer it returns the redirect without contacting origin. On a CDN miss, the API checks Redis. On a Redis miss, it queries the sharded DB, writes the result back to Redis, and responds. Most redirects short-circuit at the CDN or Redis layer and never touch the database.

```mermaid
sequenceDiagram
    participant Browser
    participant CDN
    participant API as Shortener API
    participant Redis
    participant DB as Sharded DB
    Browser->>CDN: GET /aZx4Q3
    alt CDN hit
        CDN-->>Browser: 302 (cached)
    else CDN miss
        CDN->>API: GET /aZx4Q3
        API->>Redis: GET aZx4Q3
        alt Redis hit
            Redis-->>API: long_url
            API-->>Browser: 302 Found
        else Redis miss
            API->>DB: SELECT long_url WHERE code='aZx4Q3'
            DB-->>API: long_url
            API->>Redis: SET aZx4Q3 long_url EX 86400
            API-->>Browser: 302 Found
        end
    end
```

The write path is quieter: `POST /shorten` arrives, the ID generator picks a unique ID in the shard's pre-allocated range, encodes it to base62, and inserts into the right DB shard. The response goes back to the client. No cache warming on write — reads populate Redis lazily on the first redirect.

## Click analytics

We do **not** want to update `click_count` synchronously on every redirect — that turns a 1ms read into a write-contention nightmare on hot URLs. A code that gets tweeted by a celebrity might see 10,000 clicks per second; serializing that through a counter column in Postgres is a recipe for lock contention.

The fix is fire-and-forget into Kafka:

```mermaid
sequenceDiagram
    participant U as User
    participant API as Redirect API
    participant K as Kafka
    participant W as Counter Worker
    participant DB as Analytics DB
    U->>API: GET /aZx4Q3
    API->>U: 302 Redirect
    API->>K: emit ClickEvent
    K->>W: deliver
    W->>DB: increment counter (batched)
```

Workers batch updates — flush every second per code — and write to an analytics-friendly store like ClickHouse, BigQuery, or Cassandra. The redirect path itself never blocks on a write.

## Caching strategy

URLs are massively read-heavy, so cache at every layer you can:

| Layer | TTL | Hit rate goal |
| --- | --- | --- |
| CDN | 1 day | 90% (most popular) |
| Redis | 1 day, LRU | 99% of DB reads |
| DB buffer pool | (managed) | (rest) |

When a code expires, you have two options. The lazy approach: redirect anyway, check expiry on the DB hit, and evict from cache at that point. The eager approach: subscribe to a CDC stream from the DB and invalidate proactively. Lazy is simpler and usually fine — an expired link being redirected for a few extra seconds is rarely a hard business requirement.

## Custom aliases

For aliases ("`/promo`"), do an explicit `INSERT ... ON CONFLICT DO NOTHING`. If another row already owns "promo", return 409. To prevent squatting, restrict aliases to authenticated users or charge for them.

## What breaks

The V5/V6 design is boring on the happy path. Here's where it isn't.

**Cache stampede on a freshly-viral code.** A code that just got tweeted to millions isn't in Redis yet — the first wave of clicks all miss at once and stampede the same DB shard. At 10k clicks/s that's 10k identical `SELECT`s in the ~5ms it takes the first one to populate the cache. Fix it with request coalescing (singleflight): the first miss for a code takes a short per-code lock, fetches once, populates Redis, and every other request waits on that single in-flight fetch. One DB read instead of thousands.

**Lookups of codes that don't exist.** An attacker scanning random 6-char codes misses Redis *and* the DB on every request — the miss path is the expensive path, so a nonexistent-code flood sails past your entire cache and lands 100% on Postgres. Cache negative results (`SET code "" EX 60`) so a repeated bad code stays cheap, and put a bloom filter of known codes in front of the shards so most made-up codes are rejected before any query runs.

**Counter-service outage.** If the central counter service goes down, can you still mint codes? Yes — as long as each ID-generator instance is holding a pre-claimed block of a million IDs, it keeps minting locally straight through the outage; you only stall once every instance drains its buffer. Run the counter service HA (Raft or ZooKeeper) anyway, but the buffered ranges are what turn a counter outage into a non-event for minutes at a time.

**Redis node loss.** Losing the hot-set node loses no data — it's a cache — but it dumps the full read load onto the DB shards until the replacement warms up. Run Redis with replicas and fail reads over; the DB can absorb a brief unshielded burst because the working set is only ~1 GB, but a cold cache during peak is exactly the scenario to load-test before it happens for real.

**Resharding the DB.** Adding Postgres shards remaps `hash(code)` and, with plain modulo hashing, moves nearly every row. That's why the storage layer uses consistent hashing — adding a shard moves only about `1/N` of the keyspace instead of almost all of it. Size capacity so resharding stays rare regardless.

## Trade-offs worth discussing

### Same long URL → same short code?

There are two reasonable readings. Deduplication — always return the same code for the same long URL — saves storage but means the first user to shorten a link effectively "owns" its analytics. Always-new — each `POST` returns a fresh code — is simpler and lets you track different campaigns separately. Most production shorteners (bit.ly included) do always-new for exactly that reason.

### What if the long URL is malicious?

Check against the Google Safe Browsing API on creation. Block known phishing and malware URLs, and re-check periodically — a URL that was clean on day one can become malicious later.

### 301 vs 302?

A 301 (permanent redirect) lets browsers cache the mapping aggressively. Faster. But once a browser has cached it, a repeat click from that browser never touches your infrastructure again, so it drops out of per-click analytics. A 302 with no cache headers keeps browsers from reusing the mapping, so every click reaches your infrastructure — either origin or the CDN edge, which you also control and can log. That distinction matters here: the day-long edge cache serves ~90% of clicks without hitting origin, so those clicks are recovered from CDN edge logs (shipped into the same Kafka pipeline) rather than the redirect handler. Reach for 302 when per-click accuracy matters.

Be honest about practice, though: bit.ly famously returns 301s and accepts some repeat-click blindness — its analytics survive because most clicks are first-time visits from distinct browsers. TinyURL and Twitter's `t.co` are 301s too. So 301 is a perfectly defensible default; use 302 (or 301 with edge-log analytics) specifically when you need every click counted.

### Rate limiting

Without limits, abusers will create millions of short URLs as spam infrastructure. Enforce per-API-key and per-IP limits at the gateway. See [the rate limiter article](/system-design/articles/design-rate-limiter) for the algorithms.

## Stretch — multi-region

For global low-latency redirects, replicate the URLs DB asynchronously across regions. The CDN does most of the heavy lifting — only write traffic actually crosses regions. A brand-new code might not be visible in a far region for 1–2 seconds, and that's fine for this use case: the person sharing a link sends it before anyone else can click it.

## What interviewers look for

- Did you do the back-of-the-envelope? (Surprisingly common to skip.)
- Did you separate read and write paths? (Most candidates don't.)
- Did you choose collision-free code generation, and explain how to scale the ID issuer?
- Did you address the analytics problem _without_ killing redirect latency?
- Did you talk about caching at multiple layers?

## Bonus discussion topics

- _"How would you handle 1B URLs/day instead of 100M/month?"_ — sharding, multi-region, async writes.
- _"What if some URLs expire?"_ — TTL on cache, soft delete, background sweeper.
- _"How would you prevent abuse?"_ — rate limit, CAPTCHA, malicious URL detection.
- _"How would you do A/B test redirects?"_ — `code` → list of `long_url`s with weights, server picks at redirect time.

## Frequently asked questions
Q: Why use a 302 redirect instead of a 301 for URL shorteners?
A: A 301 (permanent redirect) lets browsers cache the mapping, so a repeat click from the same browser never reaches your infrastructure and drops out of per-click analytics. A 302 with no cache headers keeps browsers from caching the mapping, so clicks that reach your infrastructure stay observable. bit.ly famously uses 301 and accepts some repeat-click blindness; reach for 302 (or 301 plus edge-log analytics) when per-click accuracy is a requirement.

Q: How does counter plus base62 encoding eliminate collisions without a global bottleneck?
A: Each ID-generator instance pre-claims a counter block from a central counter service — one instance gets IDs 0 to 1M, the next 1M to 2M, and so on — and mints codes from its block with zero coordination. The resulting row is a single-shard write to whichever storage shard hash(code) selects. The claim operation, a simple atomic increment on the counter service, occurs at most once per million inserts, making it essentially free on the hot path.

Q: What storage capacity does a URL shortener need for 6 billion URLs over 5 years?
A: At roughly 500 bytes per row (long URL plus short code plus metadata), 6 billion URLs requires approximately 3 TB. The article notes this fits comfortably on a sharded Postgres or DynamoDB deployment.

Q: How should click analytics be handled without degrading redirect latency?
A: Rather than updating a click_count column synchronously on each redirect — which causes lock contention on hot codes that might see 10,000 clicks per second — the API fires click events to Kafka in a fire-and-forget manner. Counter workers consume those events, batch updates per second per code, and write to an analytics store like ClickHouse or BigQuery. The redirect path itself never blocks on a write, keeping p50 latency around 1ms.

Q: What read-to-write ratio should you design for, and how does caching exploit it?
A: URL shorteners are 100:1 to 1000:1 read-heavy. The hot working set follows a Zipfian distribution: roughly 2 million hot codes at 500 bytes each fit in approximately 1 GB, which sits on a single Redis node. With Redis in front of the database, more than 99% of redirects never touch Postgres, dropping p50 latency to around 1ms and DB load by 100x.
