Skip to main content

URL Shortener: Requirements & High-Level Design

Editorial

Problem Statement

Design a URL shortener like bit.ly: a service that takes a long URL and generates a shorter alias — a unique short link — and redirects users to the original URL when someone opens it. It should also track link usage, keeping click counts for every short link. The service must handle millions of URLs and high traffic, respond efficiently in near real-time, and keep every mapping persistent.

The next sections derive the design from this statement: its verbs become the functional requirements, its adjectives the non-functional ones, and its nouns the data model.

Introduction

A URL shortener is a service that converts long, unwieldy URLs into compact aliases that redirect to the original destination. Services like bit.ly, TinyURL, and Twitter's t.co handle billions of redirects daily, turning links like https://example.com/products/category/electronics/item?id=12345&ref=campaign into something as short as https://short.ly/a1B2c3.

Behind the simplicity lies a system with interesting engineering constraints. The write path must generate a globally unique short code in milliseconds. The read path must resolve that code back to the original URL and issue an HTTP redirect with sub-10 ms latency — because redirects sit in the critical path of every click. And the analytics path must count billions of clicks without slowing down redirection.

The core engineering challenges are:

  1. ID Generation — How do you produce short, unique identifiers across a distributed fleet of servers without coordination bottlenecks?
  2. Encoding — How do you convert numeric IDs into compact, URL-safe strings? The character set and length determine how many URLs your system can represent.
  3. Read-Heavy Scale — With a 100:1 read-to-write ratio and 100M read requests per day, the vast majority of traffic is redirects. Caching strategy dominates architecture decisions.
  4. Consistency vs Availability — A newly created short URL must be resolvable globally within seconds. But cross-region replication introduces latency. How do you handle the window where a URL exists in one region but not another?

In this design, we'll build the system incrementally: first the core shortening and redirect flows, then analytics, then the deep dives into ID generation strategies, encoding trade-offs, caching architecture, and scaling via sharding.

LLD Connection: This problem connects to the Concurrent HashMap problem in Low-Level Design, where you implement the core data structure for O(1) key-value lookups. The URL shortener's in-memory cache and Base62 encoding both rely on hash-based access patterns.

How a URL shortener works, in three colour-zoned paths. WRITE PATH (shorten once, 1M per day): a USER with a long URL sends POST /api/urls/shorten (1) into the blue URL SHORTENING SERVICE, drawn as one dashed box that holds what it does: a, generate a unique ID; b, Base62-encode it; c, store short code → long URL. From the box, store goes down to the URL MAPPINGS database (PostgreSQL), and 201 Created (2) comes back to the user with the short link short.ly/a1B2c3. A card shows the long URL, https://example.com/products/category/electronics/item?id=12345&ref=campaign. READ PATH (redirect every click, 100M per day): a VISITOR who clicks sends GET /a1B2c3 (3) to the green REDIRECT service (under 10 ms), which looks the code up (4) in the Redis CACHE (about 90% hits) or, on a miss, in the URL MAPPINGS database, and answers 302 Found (5); the browser follows the Location header (6) to the ORIGINAL PAGE, example.com/products/…. After the 302, the Redirect service sends a click event (7) down to the ANALYTICS PATH (async, never blocks the redirect): a QUEUE, then +1 click per link. A row of key metrics: 100M daily users, 1M writes per day, 100M reads per day, 100 : 1 read : write, under 10 ms redirect p99. The title reads: a URL shortener: write once, redirect every click.

Functional Requirements

We extract the core operations by analyzing the verbs in the problem statement:

  • "takes a long URL and generates a shorter alias" → CREATE operation
  • "redirects users to the original URL" → READ operation
  • "track link usage" → UPDATE/INCREMENT operation

Each verb maps to a functional requirement:

FR1 — URL Shortening. Users submit a long URL and receive a unique, shortened alias. The short URL uses a compact format with English letters and digits (Base62: a-z, A-Z, 0-9) to save space and ensure uniqueness. The mapping between short and long URLs is permanent and immutable — once created, a short URL always points to the same destination.

FR2 — URL Redirection. When a visitor accesses a shortened URL, the service resolves the alias to the original URL and issues an HTTP redirect (302 Found) with minimal delay. The redirect must work from any geographic region.

FR3 — Click Analytics. The system tracks how many times each shortened URL is accessed. This counter is eventually consistent: it doesn't need to be exact in real time, and an occasional click event lost or replayed when a component crashes is acceptable, but counts must never be lost in bulk or drift.

Out of Scope

  • Authentication and authorization — Who can create URLs or access analytics (handled by a separate auth service)
  • URL expiration — TTL-based deletion of old URLs (can be added as a background job later)
  • Custom aliases — User-chosen vanity URLs (short.ly/my-brand) add complexity to uniqueness guarantees
  • Advanced analytics — Geographic tracking, device types, referrer analysis (beyond basic click count)
  • Link preview / unfurling — OpenGraph metadata fetching for social media previews
The three functional requirements of a URL shortener as three coloured columns, each from a verb in the problem statement. FR1 SHORTEN (blue): the verbs “takes … generates” become CREATE; a long URL goes into the SHORTENING service and comes out as a short URL; endpoint POST /api/urls/shorten; write path, 1M per day; permanent mapping. FR2 REDIRECT (green): “redirects” becomes READ; a short URL goes into the REDIRECT service, which answers 302 → long URL; endpoint GET /{shortCode}; read path, 100M per day; from any region. FR3 ANALYTICS (purple): “track” becomes UPDATE/INCREMENT; a click event goes into the ANALYTICS service, which adds +1 click; no public endpoint, event-driven; 1 event per redirect; eventually consistent. A dashed band lists what is OUT OF SCOPE: authentication, authorization, URL expiration, custom aliases, advanced analytics, link previews. The title reads: three verbs, three core operations.

Non-Functional Requirements

We extract quality constraints from the adjectives in the problem statement:

  • "unique" alias → System must guarantee no collisions
  • "millions of URLs" + "high traffic" → System must handle large scale
  • "efficiently" + "near real-time" → System must respond quickly
  • "persistent" → System must not lose data
RequirementTargetRationale
High Availability99.99% uptimeShort URLs are embedded in emails, social posts, printed media — downtime means broken links everywhere
Low Latency< 10 ms p99 for redirectsRedirects sit in the critical path of every click; users perceive any delay as a "broken link"
High DurabilityZero data lossA shortened URL must work forever; losing the mapping means permanently broken links
UniquenessZero collisionsEach short code must map to exactly one long URL across all time and all regions
ConsistencyNewly created URLs resolvable within 5 seconds globallyAcceptable delay between creation and global availability
SecurityPrevent malicious URL creationBlock phishing, malware, and spam URLs; prevent enumeration attacks on short codes

Resource Estimation

Traffic Estimation

MetricCalculationResult
Daily Active Users (DAU)Given100M
Write requests per dayGiven1M URLs shortened/day
Read:write ratioGiven100:1
Read requests per day1M × 100100M redirects/day
Write QPS (average)1M / 86,400~12 writes/sec
Read QPS (average)100M / 86,400~1,160 reads/sec
Peak read QPS (3× average)1,160 × 3~3,500 reads/sec

Storage Estimation

DataSizeRecordsTotal
URL mapping~500 bytes (short code + original URL + metadata)1M/day × 365 days × 5 years1.825B records
Total storage500 bytes × 1.825B~912 GB (~1 TB)
Analytics record~50 bytes (short code + counter + timestamp)1 per URL mapping~91 GB

ID Space Estimation

EncodingCharactersLengthCapacitySufficient?
Base62 (6 chars)a-z, A-Z, 0-9662⁶ = 56.8 billion✓ (30× our 5-year need)
Base62 (7 chars)a-z, A-Z, 0-9762⁷ = 3.5 trillion✓ (far more than needed)
Base16 hex (8 chars)0-9, a-f816⁸ = 4.3 billion✓ (but wastes 2 chars)

6-character Base62 provides 56.8 billion unique codes — 30× our 5-year requirement of 1.825 billion URLs. This leaves ample headroom for growth.

Bandwidth Estimation

OperationPayloadQPSBandwidth
Shorten (write)~1 KB request12/sec12 KB/sec (negligible)
Redirect (read)~500 bytes response1,160/sec580 KB/sec
Analytics increment~100 bytes event1,160/sec116 KB/sec

The system is read-dominated and storage-modest — classic traits of a caching-friendly architecture.

Data Model

The data model emerges from the nouns in the problem statement:

  • "URL" and "alias" → URLMapping entity with short_url and original_url fields
  • "link usage" and "click counts" → Analytics entity with click_count field
  • "persistent" requirement → created_at timestamp for durability tracking

Ownership is distributed across services to enable independent scaling. The URL Shortening Service owns URLMapping (write-optimized). The Analytics Service owns Analytics (increment-optimized).

URL Mapping Table

CREATE TABLE url_mappings (
    short_code  VARCHAR(7) PRIMARY KEY,   -- Base62 encoded, 6-7 chars
    original_url TEXT NOT NULL,            -- Destination URL (up to 2048 chars)
    created_at  TIMESTAMP DEFAULT NOW(),   -- Creation time
    creator_ip_hash BYTEA,                 -- HMAC of the creator's IP, for abuse detection
    is_active   BOOLEAN DEFAULT TRUE       -- Soft-delete: FALSE once deactivated or expired
);

-- Index for reverse lookup (optional: "does this long URL already have a short code?")
CREATE INDEX idx_original_url ON url_mappings(original_url) WHERE is_active = TRUE;

The short_code is the primary key — every redirect performs a lookup by this field. Using it as the primary key ensures a single B-tree index serves both write (INSERT) and read (SELECT) paths.

Why is_active instead of DELETE? When malicious URLs are detected, we soft-delete by setting is_active = FALSE. This preserves the audit trail for abuse investigations and prevents the short code from being reassigned (which would break cached redirects).

Analytics Table

CREATE TABLE url_analytics (
    short_code      VARCHAR(7) PRIMARY KEY REFERENCES url_mappings(short_code),
    click_count     BIGINT DEFAULT 0,
    last_accessed   TIMESTAMP,
    created_at      TIMESTAMP DEFAULT NOW()
);

URLMapping and Analytics have a one-to-one relationship, and it is optional: a short URL gets its analytics row with the first flush after its first click, so a link that was never clicked has none.

The analytics table is separated from the mapping table to isolate the high-frequency increment path (every redirect) from the low-frequency write path (URL creation). Counter updates never lock or bloat the mapping rows that every redirect reads, and each table can be tuned for its own workload.

The URL shortener data model: two tables in ONE POSTGRESQL DATABASE, joined by short_code. On top, the nouns of the problem statement map to the model: “URL” and “alias” → URLMapping, “link usage” and “click counts” → Analytics, “persistent” → created_at. The blue table url_mappings (URLMapping): short_code VARCHAR(7), PK, Base62, 6–7 chars; original_url TEXT, up to 2,048 chars; created_at TIMESTAMP; creator_ip_hash BYTEA, an HMAC of the creator's IP; is_active BOOLEAN, the soft delete flag; an INDEX on original_url for an optional reverse lookup; and the rule is_active = FALSE once deactivated or expired. Its owner is the URL Shortening service, write-optimized. A crow's-foot line marked 1 : 0..1 joins it to the purple table url_analytics (Analytics): a link gets its analytics row with the first flush after its first click. url_analytics: short_code VARCHAR(7), PK and FK → url_mappings; click_count BIGINT; last_accessed TIMESTAMP, set by each flush; created_at TIMESTAMP. Its owner is the Analytics service, increment-optimized. The title reads: two tables, joined by short_code.

API Endpoints

We derive API endpoints directly from the functional requirements (the verbs identified earlier):

  • CREATE → POST /api/urls/shorten (accepts longUrl, returns shortUrl)
  • READ → GET /{shortCode} (returns 302 redirect to original URL)
  • UPDATE → Handled internally via event-driven architecture (not a public endpoint)

Shorten URL

POST /api/urls/shorten

Request Body:

{
  "longUrl": "https://example.com/very/long/path?param=value"
}

Response (201 Created):

{
  "shortUrl": "https://short.ly/a1B2c3",
  "shortCode": "a1B2c3",
  "createdAt": "2026-03-17T10:30:00Z"
}
Status CodeMeaning
201 CreatedURL successfully shortened
200 OKURL already shortened: returns the existing short URL
400 Bad RequestInvalid URL format
429 Too Many RequestsRate limit exceeded

Redirect

GET /{shortCode}

Response (302 Found):

HTTP/1.1 302 Found
Location: https://example.com/very/long/path?param=value
Cache-Control: no-store
Status CodeMeaning
302 FoundRedirect to original URL
404 Not FoundShort code doesn't exist
410 GoneURL was deactivated (malicious content)

Why 302 instead of 301? A 301 Moved Permanently tells browsers to cache the redirect indefinitely — subsequent visits bypass our server entirely. This breaks analytics (we can't count clicks we never see) and makes URL deactivation impossible (the browser never asks us again, so it never sees our 410 response). A 302 Found with Cache-Control: no-store ensures every click hits our server, enabling accurate counting and immediate deactivation.

Get Analytics (Internal)

GET /api/urls/{shortCode}/analytics

Response:

{
  "shortCode": "a1B2c3",
  "clickCount": 42583,
  "lastAccessed": "2026-03-17T15:22:00Z",
  "createdAt": "2026-01-15T08:00:00Z"
}

High Level Design

We build the architecture incrementally, adding components as each functional requirement demands them. This approach ensures every component exists for a reason — we're solving real problems, not drawing boxes for theoretical completeness.

1. URL Shortening (FR1)

Requirement: Users submit a long URL and receive a unique shortened alias.

The simplest architecture: a single service that generates an ID, encodes it as Base62, stores the mapping, and returns the short URL.

FR1, shortening a URL. Across the top, a CLIENT sends POST /api/urls/shorten (1) to the blue API GATEWAY, which checks and rate-limits the request; 201 Created (5) comes back under it with short.ly/a1B2c3. The gateway passes the request down, shorten (2), into the URL SHORTENING SERVICE, drawn as one dashed box that holds what it does: a, generate an ID: this server's prefix + next sequence; b, Base62-encode the sequence: 5 characters, zero-padded; c, store a1B2c3 → long URL, return the short URL. From the box, INSERT (3) goes down to url_mappings (PostgreSQL), which answers OK (4), and the response goes back up to the gateway. A panel decodes the short code a1B2c3 as six key caps: the first, a, is the PREFIX (62 machines); the other five, 1B2c3, are the SEQUENCE in BASE62 (916M per machine); 62⁶ = 56.8 billion codes, unique without coordination: enough for ~155 years at 1M a day. The title reads: FR1: a long URL in, a 6-character code out.

ID Generation: Each server is assigned a unique machine prefix (e.g., A, B, C). Within each server, an auto-incrementing sequence number generates IDs. The combination MachineID + SequenceNumber is globally unique without coordination.

Why this approach? Alternative approaches (hash functions, UUIDs, Snowflake IDs) either produce IDs that are too long or require coordination between servers. Machine ID + Sequence gives us:

  • Short IDs — we control the length precisely
  • No coordination — each server generates IDs independently
  • Scalable — add more servers by assigning new machine prefixes

Base62 Encoding: The numeric ID is converted to a 6-character string using the 62 digits 0-9, A-Z, a-z, in that order:

import string

BASE62_CHARS = string.digits + string.ascii_uppercase + string.ascii_lowercase  # 0-9, A-Z, a-z: '0' is the zero digit

def base62_encode(num: int) -> str:
    """Encode an integer to a Base62 string."""
    if num == 0:
        return BASE62_CHARS[0]
    result = []
    while num > 0:
        num, remainder = divmod(num, 62)
        result.append(BASE62_CHARS[remainder])
    return ''.join(reversed(result))

def base62_decode(s: str) -> int:
    """Decode a Base62 string back to an integer."""
    num = 0
    for char in s:
        num = num * 62 + BASE62_CHARS.index(char)
    return num

# Examples:
# base62_encode(123456)    → "W7E"
# base62_encode(56800235583) → "zzzzzz" (max 6-char value)

With 6 characters, Base62 supports 62⁶ = 56.8 billion unique codes. At 1M URLs/day, that's enough for 155 years before exhaustion — far beyond our 5-year planning horizon.

2. URL Redirection (FR2)

Requirement: Clicking a short URL redirects to the original URL with minimal delay.

Redirects are the dominant traffic pattern — 100× more frequent than URL creation. A naive approach (query the database for every redirect) would require 1,160 QPS to the database. With a caching layer, we can serve the vast majority of redirects from memory.

FR2, redirecting a short URL. Across the top, a VISITOR sends GET /a1B2c3 (1) to the blue API GATEWAY, which routes the GET; 302 Found (7) comes back under it with the header Location: long URL. The gateway passes the request down, resolve (2), into the green REDIRECT HANDLER, drawn as one dashed box because the cache library runs inside it. The handler calls cache.get(short_code) (3) on the read-through CACHE LIBRARY and answers 302 if found, else 404. The library's steps: 4, GET from Redis; on a hit, return it; 5, on a miss, SELECT the row from url_mappings (PostgreSQL); 6, SET it in Redis with a TTL of 24 h. Arrows run from the library to REDIS (1 GB holds the top ~2M URLs) and, on a miss, down to url_mappings and back with the row; the handler never talks to the database, and Redis never queries it. Four tiles: ~93% of redirects hit; ~1–2 ms per hit; +5–10 ms for a miss, one database read; database reads drop from 1,160 to ~80 per second. The title reads: FR2: every redirect reads through the cache.

Read-through caching: When the Redirect Handler receives a request, it asks the cache layer: a read-through cache library in front of Redis. On a cache miss, the library fetches the URL from the database, stores it in Redis, and returns it to the handler (Redis itself cannot query a database). The handler never talks to the database directly — the cache layer is its single read path.

Why read-through instead of cache-aside? With cache-aside, the application must implement cache-miss logic (check cache → miss → query DB → populate cache). With read-through, the cache library handles this transparently. The Redirect Handler's code is simply:

original_url = cache.get(short_code)  # Read-through: auto-fetches from DB on miss
if original_url:
    return redirect(original_url, status=302)
return not_found(404)

Cache sizing: With 100M reads/day and Zipfian distribution (20% of URLs get 80% of traffic), caching the top ~2M URLs serves about 93% of redirects (sized in the caching deep dive). At 500 bytes per entry: 2M × 500B = 1 GB — easily fits in a single Redis instance.

TTL strategy: Set TTL to 24 hours for most URLs. Short URLs that are accessed frequently will be refreshed by natural reads. URLs that go cold expire from cache automatically, freeing memory for active URLs.

3. Link Analytics (FR3)

Requirement: Track the number of times each shortened URL is accessed.

Analytics must not slow down redirects. The redirect path returns a 302 in < 10 ms. Analytics recording happens asynchronously after the redirect response is sent.

FR3, counting clicks. Top zone, REDIRECT PATH · SYNCHRONOUS: a VISITOR sends GET /a1B2c3 (1) to the green REDIRECT HANDLER, which answers 302 Found in under 10 ms (2). Only after the 302 does the handler send a click event (3), fire-and-forget, into the purple ANALYTICS PATH · ASYNCHRONOUS: a QUEUE (Kafka / SQS), from which the ANALYTICS WORKER consumes (4) every click event and runs HINCRBY clicks:pending a1B2c3 1 (5) on REDIS (atomic, O(1), any QPS). A blue FLUSH JOB, every 60 s and scheduled, is one dashed box with its steps inside: a, RENAME clicks:pending to clicks:batch:<id>; b, apply each batch once, in one transaction with its id and counts; c, DEL the batch, a retry skipping batches already applied. The job sends its commands to Redis (6) and gets the batch counts back, then writes them to url_analytics (PostgreSQL) in one transaction (7). A card, WHY A 60 S FLUSH?: a database write per click is 1,160 transactions/s; a flush every 60 s is 1 transaction a minute; 1 row update per link a minute; the total lags up to a minute. A note, LIVE COUNT: the database total + the pending clicks in Redis. The title reads: FR3: count every click off the redirect path.

Two-tier counting:

  1. Redis HINCRBY — For every click event it reads from the queue, the Analytics Worker runs HINCRBY clicks:pending {short_code} 1: an atomic O(1) increment in one Redis hash that handles any QPS. A link's live count is its database total plus its pending clicks in Redis.

  2. Periodic DB flush — Every 60 seconds, a background job moves the pending clicks into the analytics table, exactly once even if it crashes halfway:

from uuid import uuid4

def flush_analytics_to_db():
    """Runs every 60 seconds (one scheduled job). Moves the pending clicks into the database exactly once."""
    # Take the pending counters atomically: clicks that arrive from now on start a fresh hash
    if redis.exists("clicks:pending"):
        redis.rename("clicks:pending", f"clicks:batch:{uuid4()}")

    # Apply every batch not applied yet, including one left behind by a crashed run
    for batch_key in redis.scan_iter("clicks:batch:*"):
        counts = redis.hgetall(batch_key)  # {short_code: clicks}
        with db.transaction():
            # Recording the batch id in the same transaction makes a retry a no-op
            first_time = db.execute(
                "INSERT INTO applied_batches (batch_id) VALUES (%s) ON CONFLICT DO NOTHING",
                (batch_key,),
            ).rowcount == 1
            if first_time:
                db.executemany(
                    """INSERT INTO url_analytics (short_code, click_count, last_accessed)
                       VALUES (%s, %s, NOW())
                       ON CONFLICT (short_code) DO UPDATE
                       SET click_count = url_analytics.click_count + EXCLUDED.click_count,
                           last_accessed = NOW()""",
                    [(code, int(clicks)) for code, clicks in counts.items()],
                )
        redis.delete(batch_key)

Why not increment the DB directly? At 1,160 QPS, direct UPDATE statements would generate 1,160 transactions/sec on the analytics table. With a 60-second flush, all clicks on the same link within that minute collapse into one row update, and the whole minute is written in a single transaction. Because clicks concentrate on popular links, far fewer rows are written than clicks received; the exact saving depends on how many distinct links are clicked each minute. The trade-off: the database total can be up to a minute behind, and a live count adds the pending clicks from Redis.

4. Complete Architecture

The complete URL shortener architecture. CLIENTS (web, mobile, API) reach the blue API GATEWAY / LOAD BALANCER. POST /api/urls/shorten goes to the URL SHORTENING service (12 writes/s), one dashed box with its ID GENERATOR (prefix + sequence) inside; the service INSERTs url_mappings on the PostgreSQL PRIMARY. GET /{shortCode} goes to the REDIRECT HANDLER (1,160 reads/s), one dashed box with its read-through CACHE LIBRARY inside; the library reads the REDIS CACHE (1 GB, ~93% hits) and, on a miss, a READ REPLICA. The PostgreSQL zone is one database: the PRIMARY holds url_mappings and url_analytics and replicates to the READ REPLICAS. After the 302, the handler sends a click event into a QUEUE (Kafka / SQS); the ANALYTICS SERVICE, one dashed box, has WORKERS that consume it and run HINCRBY on the REDIS COUNTERS (clicks:pending), and a FLUSH JOB, every 60 s, that takes the counts and writes url_analytics on the primary once a minute. The note: reads outnumber writes 100 : 1, the cache hit rate sets the cost. The title reads: three services, one database, one queue.

The architecture has three independent scaling paths:

ServiceScaling StrategyBottleneck
URL ShorteningAdd servers with new Machine IDsID generation throughput (rarely an issue at 12 QPS)
Redirect HandlerHorizontal scaling (stateless) + Redis clusterCache miss rate → DB read IOPS
Analytics ServiceRedis sharding + batch write workersRedis memory for counters

Key property: The Redirect Handler is stateless — any instance can serve any redirect. This makes horizontal scaling trivial: add more instances behind the load balancer.

5. Sequence Diagrams

Sequence: Create Short URL

Sequence diagram for creating a short URL, with four lifelines: CLIENT, API GATEWAY, URL SHORTENING (service) and url_mappings (PostgreSQL). 1, the client sends POST /api/urls/shorten to the gateway; 2, the gateway validates and rate-limits it; 3, it calls shorten(longUrl) on the service. 4, inside the service, in one dashed box on its lifeline: next_id() takes the prefix a and the next sequence, 17,407,991, Base62-encodes it to 5 characters, 1B2c3, and the short code is a1B2c3. 5, the service INSERTs a1B2c3 → long URL into url_mappings; 6, the database answers OK; 7, the service returns short.ly/a1B2c3 to the gateway; 8, the gateway answers 201 Created. A note: no url_analytics row yet, the first 60 s flush creates it. The title reads: sequence: create a short URL.

Sequence: Redirect (Cache Hit)

Sequence diagram for a redirect served from the cache, with five lifelines: VISITOR, API GATEWAY, REDIRECT HANDLER (+ cache library), REDIS CACHE and QUEUE (Kafka / SQS). 1, the visitor sends GET /a1B2c3 to the gateway; 2, the gateway calls resolve on the handler; 3, the handler's cache library, a short dashed activation marked cache.get, sends GET url:a1B2c3 to Redis; 4, a hit, ~1 ms, returns the long URL; 5, the handler answers 302 Found to the gateway; 6, the gateway sends the 302 with the Location header to the visitor; 7, only then does the handler send a click event to the queue. A note: later, off the request, an Analytics Worker consumes it and runs HINCRBY clicks:pending a1B2c3 1. A cache hit, end to end: ~2–5 ms. The title reads: sequence: a redirect, from the cache.