1. Requirements & Estimation
Same functional core as Week 1's example, with two realistic additions a real product would ask for: an optional custom alias, and link expiration.
Functional:
- A user can submit a long URL and receive a short one back
- A user can optionally request a custom alias instead of a
generated code (e.g. short.ly/my-launch)
- Visiting the short URL redirects to the original long URL
- A user can optionally set an expiration date for a link
- A user can see click-count analytics for their link
Non-functional:
- Scale: 100M new short URLs created per month
- Read-heavy: reads (redirects) outnumber writes ~100:1
- Latency: redirect must happen in well under 100ms
- Availability: favor uptime over strict consistency
- Durability: a short URL must never silently stop working
- Abuse resistance: the creation endpoint must not be usable to
spam-generate links, and the redirect endpoint must be resilient
to scraping -- this week's new requirement, driving Section 4
The estimation math carries over unchanged from Week 1: roughly 40 writes/second and 4,000 reads/second on average, peaking near 12,000 reads/second, with about 3 TB of storage over five years. What's new this week is turning "6 billion records over 5 years" into an actual encoding decision — Section 3 picks up exactly there.
When a case study builds on a prior week's example, reusing the earlier estimate and citing it is stronger than silently redoing the arithmetic — it shows you're building one coherent design instead of starting over. In an interview, this looks like referring back to your own numbers from ten minutes ago rather than re-deriving them from scratch.
2. High-Level Design
The core write and read paths are the same shape Week 1 sketched, now with the two new pieces this week's requirements demand: a gateway in front of everything (Week 11) and an explicit place for expiration to be checked.
[Client] --> [API Gateway: auth, rate limiting] --> [App Servers] --> [Database]
|
+--> [Cache] (short code -> long URL)
Write path (create):
Client -> Gateway (rate-limit check) -> App Server
-> generate/validate short code (Section 3)
-> store {short_code, long_url, expires_at} in Database
-> return short URL to client
Read path (redirect):
Client -> Gateway (rate-limit check) -> App Server
-> Cache lookup by short_code
hit -> check expires_at -> not expired -> 302 redirect
miss -> Database lookup -> populate cache -> 302 redirect
-> expired -> 410 Gone (do not redirect)
Expiration is checked on the read path rather than actively deleted by a background
job the moment it lapses — an expired row can sit in the database and cache
indefinitely without harm, because every redirect already checks expires_at
before honoring the cached entry. A periodic cleanup job can reclaim storage later, but
it's a housekeeping concern, not a correctness one — correctness lives entirely in the
read path's check.
Actively scanning for and deleting expired rows the instant they lapse adds a background job, a scheduling concern, and a race with in-flight reads, to solve a problem the read path can already solve for free with one extra comparison. Reach for active cleanup only when stale data left in place actually causes harm (e.g. it's exposed somewhere it shouldn't be) — here, it's purely a storage-reclamation nicety.
3. Deep Dive: Short-Code Generation
There are two standard approaches, and the choice between them is a real design decision worth walking through rather than picking arbitrarily. Hash-based generation takes the long URL, runs it through a hash function (MD5 or SHA-256), and truncates the result to a handful of characters, base62-encoded (0–9, a–z, A–Z — 62 symbols, all URL-safe). It's stateless and simple, but truncating a hash creates real collision risk at this system's scale, which means every write needs a collision check against the database and a retry loop on collision — extra latency and complexity on the write path.
Counter-based generation instead assigns each new URL a unique, monotonically increasing integer ID and base62-encodes that ID directly. Uniqueness is guaranteed by construction — no collisions are possible, so there's no retry loop needed.
Base62 alphabet: 62 characters (a-z, A-Z, 0-9)
6-character codes: 62^6 ≈ 56.8 billion possible codes
7-character codes: 62^7 ≈ 3.5 trillion possible codes
Needed over 5 years (from Section 1's estimate): ~6 billion codes
6 characters alone would technically cover this with room to
spare, but 7 characters is the safer choice -- it leaves roughly
580x headroom over the 5-year projection, absorbing growth faster
than projected without a migration to longer codes later.
Example: ID 125 -> base62 encode -> " ...c" (a short, dense
string) -> stored as the short_code
The catch with counter-based IDs is generating the counter itself without it becoming a bottleneck or a single point of failure — a plain auto-increment column on one database instance doesn't scale once app servers are horizontally scaled (Week 3) and all contending for the next value. The standard fix is a dedicated ID-generation service that hands out ranges of IDs to each app server at a time (e.g. "you own IDs 40,001–41,000"), so an app server can mint short codes locally from its own range without a network round trip per request, only re-requesting a new range when its current one is exhausted.
A sequential, predictable ID also has a downside worth naming: it lets anyone guess
adjacent short URLs simply by incrementing the code, which can leak how many links
exist or expose other users' links. A simple mitigation — reversibly shuffling the bits
of the integer ID (or XOR-ing with a fixed key) before base62-encoding it — keeps the
collision-free guarantee of a counter while making the resulting codes non-sequential
to an outside observer. Custom aliases from this week's requirements bypass generation
entirely: they're just validated for character set and uniqueness before being stored
directly as the short_code.
Hash-based generation looks simpler on a whiteboard, but at 40 writes/second sustained, even a small collision probability means some fraction of writes pay for an extra database round trip to detect the collision, then another to retry — a cost the counter-based approach never incurs by construction. This is the kind of tradeoff worth stating explicitly rather than picking the "obvious"-looking option by default.
4. Deep Dive: Layering In the Rate Limiter
This system has two endpoints with very different abuse profiles, which means one rate-limit policy doesn't fit both. Applying Week 11's framework directly:
POST /shorten (create a new short URL)
Risk: a script spam-generating millions of links
Key: per authenticated user / API key
Algorithm: token bucket, e.g. 100 creates/day sustained,
burst of 10 -- generous enough for a real user
pasting several links in a row, tight enough that
mass-generation is throttled hard
GET /{code} (redirect)
Risk: scraping the entire code space to discover valid links,
or hammering one popular link (thundering herd on a single
row -- but Section 2's cache already absorbs that specific
case)
Key: per IP address (redirects are often anonymous, no user
session to key off)
Algorithm: sliding window counter, higher ceiling, e.g.
1,000 redirects/minute/IP -- generous for real
traffic, still bounds a scraping script
Both checks happen at the API gateway, before a request ever reaches an app server — exactly the placement Week 11 Section 3 argued for, and it means neither policy needs to be reimplemented inside the app server's business logic. Because this system runs multiple app servers behind a load balancer, the counters can't live in each server's local memory (Week 11 Section 4's exact problem) — they live in a shared Redis instance, incremented atomically per request, so a client can't dodge the limit by having requests happen to land on different servers.
A rejected request from either endpoint returns 429 Too Many Requests with
a Retry-After header, letting a well-behaved client back off automatically
instead of hammering the endpoint harder — the redirect endpoint in particular should
never fail silently or return a broken page, since this system's non-functional
requirements from Section 1 explicitly said a short URL must never silently stop
working.
A candidate who proposes one blanket rate limit for the whole API is treating a nuanced abuse-prevention problem as a single number. Naming that the create endpoint needs a per-user identity-based limit while the redirect endpoint needs a per-IP limit, and that the two ceilings should be very different, is exactly the kind of specificity that separates a strong answer from a generic one.
5. Hands-on Exercise
Extend the design with click analytics
The requirements in Section 1 mention click-count analytics per link — a feature this week's design hasn't addressed yet. Design that addition.
Requirements:
- State whether incrementing a click counter on every redirect should happen synchronously (before returning the redirect) or asynchronously (after), and justify your choice against the <100ms redirect latency requirement from Section 1.
- If asynchronous, name the component from an earlier week you'd use to decouple the redirect response from the counter update, and explain what it protects against.
- Decide where click counts are stored — the same database as the URL records, or somewhere separate — and justify it against this system's 100:1 read:write ratio and the fact that click counts update far more often than URL records do.
- Write one sentence on what happens to accuracy if the component from step 2 fails to process an update (tie this to a vocabulary term from Weeks 8–9).
- Update Section 2's architecture sketch to show the new component and where it sits relative to the existing read path.
A user waiting on a redirect does not need the click counter to be updated before the redirect happens — the redirect and the analytics update have very different latency requirements, which is exactly the signal for decoupling them the way Week 8 discussed producers and consumers.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does counter-based short-code generation avoid the collision-retry problem that hash-based generation has, and what does it cost instead?
Why does counter-based short-code generation avoid the collision-retry problem that hash-based generation has, and what does it cost instead?
Because each ID is unique by construction (a monotonically increasing counter never repeats), base62-encoding it can never produce a duplicate short code, eliminating the need to check for and retry collisions on every write. The cost shifts to generating that counter itself without contention across many app servers, typically solved by handing each server a pre-allocated range of IDs to mint from locally.
Q2
Why does the redirect endpoint check expiration on every read instead of a background job actively deleting expired links?
Why does the redirect endpoint check expiration on every read instead of a background job actively deleting expired links?
An expired row sitting in the database or cache causes no harm on its own, since every redirect already reads and compares expires_at before honoring the entry — correctness is enforced entirely at read time with one extra comparison. Active deletion would add a scheduled background job and a potential race with in-flight reads to solve a problem the read path already handles, making it a storage-reclamation optimization rather than a correctness requirement.
Q3
Why does the create endpoint use a per-user rate-limit key while the redirect endpoint uses a per-IP key?
Why does the create endpoint use a per-user rate-limit key while the redirect endpoint uses a per-IP key?
Creating a link requires authentication, so a stable per-user identity is available and is the more precise key for stopping one account from mass-generating links. Redirects are frequently anonymous with no user session to key off, so per-IP is the only identity signal available — it's a coarser proxy (multiple users can share an IP), but it's still enough to bound a scraping script without blocking legitimate anonymous traffic entirely.
Q4
Why must the rate-limit counters for this system live in a shared store like Redis rather than in each app server's local memory?
Why must the rate-limit counters for this system live in a shared store like Redis rather than in each app server's local memory?
Because this system runs multiple app servers behind a load balancer, a client's requests can land on any of them, and local-memory counters on each server would each independently allow up to the full limit — letting a client exceed the stated limit by a multiple of the server count. A shared store that every server reads and writes atomically is the only way to enforce one true count across the whole fleet, exactly the problem Week 11 Section 4 walked through.