Engineering Atlas › System Design Interviews

Design a short-link service

22 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope

Back-of-envelope

Assume 50M new links per day and a 100:1 redirect-to-create ratio. These are round planning numbers, not any company's published figures.

API

POST /v1/links
  Idempotency-Key: 7f3c...
  { "long_url": "https://shop.example/spring?utm_source=mail",
    "alias": "spring-sale",              // optional
    "expires_at": "2027-01-01T00:00:00Z" } // optional
  -> 201 { "code": "spring-sale", "short_url": "https://sho.rt/spring-sale" }
  -> 400 { "error": "invalid_alias" }   409 { "error": "alias_taken" }
  -> 422 { "error": "url_blocked" }     429 rate limited

GET /{code}
  -> 302 Location: https://shop.example/spring?utm_source=mail
     Cache-Control: private, max-age=60
  -> 404 unknown or deleted     410 expired or disabled

GET /v1/links/{code}/stats?from=2026-10-01&to=2026-10-16&granularity=day
  -> 200 { "total": 182340, "series": [...], "top_countries": [...] }

DELETE /v1/links/{code}   (owner only) -> 204

The idempotency key matters because mobile clients retry on timeouts. Without it, one tap can mint two codes for the same URL. The server keeps the key and the response for 24 hours, the same pattern described in the Idempotency lesson.

Data model

High-level design

flowchart LR
  U["Browser"] --> EDGE["CDN edge (short TTL)"]
  EDGE --> RS["Redirect service"]
  RS --> LC["In-process LRU"]
  RS --> RC["Cache cluster"]
  RS --> DB["links KV store"]
  RS --> BUF["Local event buffer"]
  BUF --> K["Click log (Kafka)"]
  K --> AGG["Stream aggregator"]
  AGG --> OLAP["Rollup store"]
  C["Creator app"] --> API["Link API"]
  API --> IDB["id_blocks (lease)"]
  API --> SCAN["URL safety check"]
  API --> DB
  API --> RC

Write path. The link API authenticates the caller, applies the per-user creation rate limit, normalises the long URL (scheme and host lowercased, 2 KB cap) and checks it against a blocklist and a URL reputation service. Pick that service with the licence in mind: Google's Safe Browsing API is for non-commercial use only, and Google points commercial products to its Web Risk API (Google Safe Browsing), whose Update API keeps hashed lists locally so you don't send every user's URL to a third party (Web Risk overview). With no alias, it takes the next number from its locally leased block, encodes it as 7 base62 characters and writes the row with a plain put, since the code is guaranteed unique. With an alias, it validates the shape and writes with a conditional put (attribute_not_exists(code)), returning 409 if the alias is taken by someone else (DynamoDB condition expressions). Finally it writes the new row into the cache cluster and deletes any negative-cache entry for that code, then returns 201.

Read path. The CDN edge answers repeat clicks on popular links from its cache. On an edge miss, the redirect service checks its in-process LRU (about 100 ns), then the cache cluster (about 0.3 ms in-region), then the key-value store (a few ms). It checks status and expires_at, appends a click event to a local buffer, and returns a 302 with the Location header. A background thread flushes the buffer to Kafka in batches every 100 ms.

Deep dives

1. The redirect hot path: status codes and cache layers

301 or 302. RFC 9110 makes a 301 "heuristically cacheable", so a cache may store it even when the response says nothing about freshness, while a 302 is not (RFC 9110, section 15.4). In practice browsers keep permanent redirects with no fixed expiry. After the first visit the browser skips you entirely: you stop seeing clicks, and a disabled phishing link keeps working for every browser that cached it. A 302 (or 307) is only cached when the response says so. I'd return 302 with Cache-Control: private, max-age=60 for browsers and let the CDN cache for 60 seconds with a surrogate key per code. Customers can opt into 301 per link.

Cache layers. The CDN absorbs viral links: 50,000 clicks per second on one code become a handful of origin requests per PoP per minute. The in-process LRU absorbs hot keys that miss the CDN. The cache cluster holds the daily working set (about 60 GB), so the database sees mostly cold links. Negative caching is the overlooked part: scanners and typos request codes that don't exist, and every miss would otherwise go to the database. Cache "not found" for 30 seconds, and have the create path delete that entry, so a code someone probed just before creation still resolves immediately afterwards. Put an explicit short Cache-Control on the 404 and 410 responses too: RFC 9110 lists both as heuristically cacheable, so leaving the header off lets a browser or proxy decide for itself how long "not found" lasts (RFC 9110, 404).

Disable within 60 seconds. A disable writes status = disabled, publishes the code on an invalidation topic that redirect nodes use to evict their LRU entry, deletes the cache key, and purges the CDN by surrogate key, a tag the origin attaches to each cached response so one purge call removes every object carrying it (Fastly surrogate keys). The browser max-age=60 bounds the worst case.

2. Allocating codes and handling custom aliases

The generator choices (random codes with retry, truncated hashes, counters) are compared in the Pastebin and Generating Unique IDs lessons. I'd use leased counter blocks here for the same reason: uniqueness without a read before every write. It is the same idea as Flickr's ticket servers, which handed out IDs from a dedicated database instead of letting each shard pick its own (Flickr). This section covers what is specific to a shortener.

Block size. At 1,750 creates per second across 20 API servers, each server uses about 90 numbers per second, so a block of 10,000 lasts almost two minutes and id_blocks sees about 10 leases per minute. A crash wastes at most one block; a crash every minute for five years wastes 26 billion, under 1% of the space. Servers prefetch the next block when the current one is half used, so a slow id_blocks database never blocks a create.

Multiple regions. Each region owns a disjoint slice of the counter space, so a region cut off from the others keeps issuing codes and failover can never produce a duplicate. The number passes through a reversible scramble before encoding, as in the Pastebin lesson, so a customer's campaign links aren't adjacent and enumerable.

Aliases. A custom alias must not collide with a code the counter will issue next year. The rule that removes that risk is about shape: generated codes are exactly 7 characters from [0-9a-zA-Z], so an alias of exactly 7 alphanumeric characters is rejected, and every other alias (4 to 30 characters of letters, digits, - and _) can only clash with other aliases. Aliases are compared case-insensitively and stored under their lowercased form, because Spring-Sale and spring-sale owned by different people is a phishing gift. A reserved list (api, admin, login, brand names) is checked first. Creating the same alias for the same URL by the same owner returns the existing link, so a retried request without an idempotency key is still safe. Any other clash returns 409.

Same URL twice. Two creates of the same long URL get different codes, even for one user, because campaigns often want separate counts for the same destination.

3. Click analytics without slowing redirects

Counting synchronously (INCR clicks:{code} in Redis per redirect) adds a network call to the hot path and makes a viral link a hot counter key. Instead, the redirect node appends a compact event to an in-memory ring buffer and returns. A flusher sends batches to Kafka with acks=all, so the leader waits for every in-sync replica before acknowledging (Kafka producer configs). If the buffer fills because Kafka is slow, the node drops events and increments a clicks_dropped metric rather than blocking redirects. That is an explicit choice: analytics are best effort to within a stated error, and redirects are not.

Delivery to Kafka is at least once, so the stream aggregator deduplicates on event_id within a short window and then rolls events up into per-minute and per-day counts by (code, country, referrer_host). Unique visitors are estimated with a HyperLogLog sketch per link per day, not by storing visitor IDs. Redis's version uses at most 12 KB with a standard error of 0.81%, and sketches merge across days (Redis HyperLogLog). Bot traffic, mostly chat-app link previews and security scanners, is excluded from customer-facing counts but kept for abuse analysis.

Variant: a text-sharing service (Pastebin)

A paste service looks like a shortener with a bigger payload, but the payload changes the design. A link row is about 500 bytes; a paste can be 10 MB, so the record splits in two. A small metadata row (ID, owner, expiry, size, content pointer) stays in the key-value store and is the only part on the strict consistency path, while the text goes to an object store. Pastes under roughly 4 KB compressed can stay inline in the row, because most pastes are tiny and an object-store round trip costs more than the data. Key-value stores also cap item size (DynamoDB at 400 KB), and large values hurt compaction (DynamoDB constraints).

The numbers move too. At 5 million new pastes a day, writes are only about 58 per second on average, far below the shortener's 1,750 creates per second. Storage is the real cost: at a 10 KB average that is 50 GB a day and around 91 TB over five years before expiry. Roughly 9 billion IDs over five years still fit in 7 base62 characters, so the counter-block allocator carries over unchanged.

Immutability is the gift. A paste never changes after it is written, so reads suit a CDN with the raw text keyed by a content-hash ETag. Redirect-style invalidation by surrogate key is replaced by expiry and delete, and the CDN max-age must be capped at the remaining lifetime so an expired paste is not served from the edge. Private pastes need private, no-store and a signed or owner-checked path, since a cache that serves one user's page to another is a data leak.

Expiry has three jobs. The origin compares expires_at on every read and returns 404, which is the correctness rule. A background sweeper or TTL feature reclaims rows eventually; DynamoDB TTL, for example, can take days, and expired items may still appear in reads until filtered. An object-store lifecycle rule on date-prefixed keys reclaims the blobs. Burn-after-read needs exactly-once behavior: the read becomes a conditional delete that returns the old item, so of two simultaneous readers one gets the text and the other a 404.

Abuse replaces click analytics as the workload that never goes away. Pastes host malware, leaked credentials and spam, so version one needs per-IP and per-account rate limits, a size cap, an asynchronous content scanner that can quarantine a paste, and a takedown path that also purges caches. Visibility needs honest names: "unlisted" hides a paste from listings but anyone with the URL can read it, which is what GitHub's "secret" gists mean (GitHub Docs).

Down the stack

Layer 1The contract

GET /{code} returns a 302 to exactly the URL stored when that code was created, or a 404 or 410. It never returns another link's URL, even across a regional failover, because codes are unique by construction and immutable. A created link resolves in its home region as soon as the 201 is returned; other regions see it within the replication lag, typically under 2 seconds. A disabled link stops redirecting within 60 seconds everywhere. Click counts are eventually consistent (about one minute behind) and may undercount by the dropped-event rate, which is published as a metric.

Layer 2The mechanism

A redirect node runs an LRU keyed by code with entries of about 300 bytes; one million entries take about 300 MB. On a miss it sends GET link:{code} to the cache cluster, where the client hashes the key onto a consistent-hash ring of cache nodes (see Consistent Hashing). On a cache miss, concurrent requests for the same code are coalesced into one GetItem on the partition key. The click event is a fixed-layout struct appended to a per-thread ring buffer: no lock and no allocation per click.

Layer 3The machine

A 302 with a 200-byte Location header is about 400 bytes, which fits in a single TCP segment (a typical MSS is 1,460 bytes), so the whole redirect is one packet out after the request arrives. For a new visitor, the TCP handshake and the 1-RTT TLS 1.3 handshake cost two round trips before the request is even sent (RFC 8446), which is why terminating TLS at an edge PoP 10 ms away instead of an origin 80 ms away matters more than any database tuning. An LRU hit costs around 100 ns, a cache cluster hit one in-region round trip of 200 to 500 µs, and a database read 2 to 10 ms. With a 95% LRU hit rate and 4.9% cache-cluster hits, the average origin lookup is under 50 µs, so the round trip to the user is the entire latency budget.

Why it's built this way

  1. Every click pays the network round trip, so the answer must come from as close to the user as possible: edge first, then memory.
  2. Caches take away your ability to change answers, so the status code and TTLs bound how long a disabled link lives.
  3. Caches need keys that never change meaning, so codes are immutable and unique by construction, which a counter gives without a read before each write.
  4. A counter needs coordination, and coordination is slow, so servers lease blocks and coordinate once every few thousand codes.
  5. Anything extra on the redirect path multiplies across 175,000 requests per second, so analytics are buffered and shipped asynchronously.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
301 redirectBrowser caches it; fewest requestsLose clicks; cannot disable for cached browsersPermanent links where control doesn't matter
302 with short max-ageEvery click counted; links can be disabledOne request per click (mostly edge hits)Default for a link service
Leased counter blocksNo collision check; dense codesCoordinator table; needs a scrambleDefault code generator
Random 7-character codesNo coordination at allConditional put and retries; collisions grow with fillSmall services or very high write fan-out
Synchronous click counterExact, real-time countsExtra hop and hot keys on the redirect pathVery low traffic only
Async click log and rollupsRedirect path untouched; rich analyticsCounts lag by about a minute; best effort under overloadAny service at scale

Leadership lens

Check yourself

  1. Why does the design default to a 302 with a short max-age rather than a 301?

    Show answer

    Browsers cache a 301 with no fixed expiry and then skip the service entirely, so clicks stop being counted and a disabled phishing link keeps working for those browsers. A 302 with Cache-Control: private, max-age=60 costs one request per click, mostly served at the edge, but keeps the service in control.

  2. Why are codes 7 base62 characters and not 6?

    Show answer

    Five years at 50M links a day is about 91B codes, more than 62^6 (about 56.8B). 62^7 is about 3.52T, which gives about 38x headroom.

  3. How can a custom alias never collide with a code the counter will generate next year?

    Show answer

    Generated codes are exactly 7 characters from [0-9a-zA-Z], so any alias of exactly 7 alphanumeric characters is rejected and every other alias can only clash with other aliases. Aliases are also stored lowercased and written with a conditional put that returns 409 if taken.

  4. Kafka slows down and the redirect node's click buffer fills. What does the node do, and why?

    Show answer

    It drops click events and increments a clicks_dropped metric rather than blocking redirects. Analytics are explicitly best effort within a stated error, while redirects are not, so a slow pipeline must never slow the hot path.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →