July 29, 2026
9 min read
backend / software-architecture / system-design
Designing a URL Shortener: The Hard Requirement Is Taking a Link Down
A URL shortener is a classic system design exercise because its read path wants caching everywhere while abuse handling needs to revoke a link at once. Working through capacity, identifiers, caching, redirects and global traffic with that tension in mind.

A phishing campaign starts using one of your short links. Support disables it in the admin panel. The database now says disabled, and a reader clicking the link should get a 404 or a warning page.
Whether they actually do depends on decisions made long before that moment: which redirect status the service returns, what the cache holds and for how long, whether the CDN stored the response, and whether the browser stored the redirect itself.
That is why I like the URL shortener as a system design exercise. The happy path, store a mapping and redirect, fits in a coding interview. Every layer that makes that path fast also makes it harder to take a link down. This post works through the design with that tension in view. It is a design exercise with assumed numbers, not a description of a system I run.
Requirements change the design more than component choices do
Before drawing anything, a few answers reshape the system:
- Is the service public, where anyone can create links, or internal?
- Can links be edited, expire, or be disabled?
- How quickly must a disabled link stop working: seconds, or "eventually"?
- Do analytics need exact counts, or are approximate trends enough?
- Is traffic regional or global?
For this post, assume a public service where links can be disabled and abuse matters. That single assumption, revocation must be fast, drives most of what follows.
Sizing it
A concrete workload helps:
- 100 million links created per month;
- 100 redirects per created link;
- five years of retention;
- peaks at 10× the average.
100,000,000 / 30 days / 86,400 s ≈ 39 writes/s
39 × 100 ≈ 3,900 redirects/s on average
3,900 × 10 ≈ 39,000 redirects/s at peak
100 million × 12 months × 5 years = 6 billion links
At roughly 500 bytes per mapping record, that is about 3 TB before indexes, replicas, backups and analytics.
The numbers tell you the shape of the problem rather than predicting the future: a read-heavy, key-value access pattern with billions of keys. The redirect path has to be short, and anything that is not needed to answer it, analytics in particular, should stay out of it.
The short code is a design decision
Base62 is an encoding, not a generation strategy. Several very different strategies produce Base62-looking codes:
- Hash the URL and truncate. Deterministic, but truncation collides, and the same URL often needs several links with different owners or expirations.
- Sequence plus Base62. Simple and compact, but codes are guessable, and the counter becomes a coordination point.
- Snowflake-style IDs. No central counter, at the cost of clock and worker-ID management.
- Random code plus unique constraint. Generate eight random Base62 characters and retry on a uniqueness violation.
For a public shortener I would choose the last option. Eight Base62 characters give about 2.2 × 10¹⁴ combinations. Even at six billion links, the chance that a new code collides is under 3 in 100,000, and a retry handles it. Codes cannot be enumerated by counting, and any instance can generate them without coordination.
The storage model stays short_code → destination plus status and metadata. PostgreSQL with a cache in front is a reasonable start for that. A distributed key-value store can wait until traffic or geography demands it.
The first architecture

A first version: one deployable application, a cache in front of PostgreSQL, and analytics pushed out of the request.
Inside one deployable application, keep two modules apart, because reads and writes behave very differently:
- Link management validates the destination, enforces quotas and alias rules, generates the code, persists it and invalidates cache entries.
- Redirect looks up the code, checks status and expiration, returns the redirect and emits an analytics event.
Redirects use cache-aside in Redis: on a hit, return the cached destination. On a miss, read PostgreSQL and fill the cache. A short-lived negative cache entry for unknown codes stops repeated lookups for the same missing code. It does nothing against a bot that sends a new random code with every request. That needs rate limiting.
Every cache between the reader and the database is a revocation problem
Back to the phishing link. Count the places that can still serve it after the database says disabled.
The browser, through the status code. 301 and 308 are permanent redirects, and in practice browsers often cache them with no expiry, without asking the server again. Once a reader's browser has stored a permanent redirect to a phishing page, disabling the link on your side does not reach that browser. 302 and 307 are temporary. For links that can be edited or revoked, a temporary redirect is the safer default. It costs some extra traffic, which is acceptable here.
A 302 by itself does not forbid caching, though. To keep browsers and intermediaries from storing the redirect, send an explicit policy such as Cache-Control: no-store. RFC 9111: HTTP Caching
The CDN or edge cache. no-store applies to shared caches too, so a response marked that way cannot be cached at the edge. If you want the edge to absorb traffic but keep browsers out, split the policy: Cache-Control: private, no-store toward browsers, and a short edge-only lifetime set through your CDN's own configuration or a CDN-specific header. Then revocation needs a purge path, and the purge latency is the number support should know.
Redis. Disabling a link should delete or overwrite its cache entry. There is a race, though: a reader misses the cache and reads the old row, the admin disables the link and deletes the key, then the reader writes the stale value back. A bounded TTL limits how long that wrong entry survives. If revocation must be immediate, store the disabled state explicitly and check it on an authoritative path, which gives up the cache for that check on every redirect, or version the entries so a stale write cannot overwrite a newer one.
Each layer exists for a good reason. The design decision is the revocation budget: the maximum time a disabled link may keep working. That budget then sets the TTLs, the cache headers and the purge mechanism. An illustrative example with a 60-second budget:
- browsers:
private, no-store, so they never hold the redirect; - edge: lifetime plus worst-case purge latency under 60 seconds, say a 30-second edge TTL with purges that complete in under 20;
- Redis: delete on disable, with a TTL of 60 seconds or less to bound the stale-refill race;
301/308: never used for links that can be disabled.
With a budget of "a few hours", the same reasoning allows long edge lifetimes and permanent redirects for links that cannot be edited.
Analytics after the redirect
Incrementing a counter on every redirect turns a read-heavy workload into a write-heavy one, especially for a viral link. Instead, return the redirect and then publish an event, which feeds aggregates and dashboards asynchronously.
Publishing after responding has a cost: if the process dies in between, the click is lost. For trend dashboards, that is usually acceptable. For billing per click, it is not. Then the event has to be accepted durably before the response, and processing must deduplicate.
Abuse is part of the architecture
A shortener hides where a link goes, which is useful to legitimate users and to attackers alike. The first version already needs:
- only
httpandhttpsdestinations; - rate limits per IP, account and organization;
- reserved aliases (
login,admin, brand names); - a moderation workflow and an audit trail;
- the fast takedown path described above.
If the service ever fetches destination pages, for previews or safety scanning, it is now making outbound requests on behalf of user input. SSRF protections become part of the design.
When traffic goes global

At global scale, the redirect path moves toward the edge, while storage and analytics become distributed.
Global readers push the redirect path closer to them: edge routing, regional redirect services, region-local caches, and a distributed key-value store such as DynamoDB, Cassandra or Bigtable for the mapping. The access pattern is still short_code → destination, which these stores handle well.
Two problems do not disappear with distribution. A single viral link is a hot key: partitioning spreads many keys, not one. Edge and regional caching absorb it, which again makes revocation latency the number to watch. Global replication also forces an earlier decision: is a new link acknowledged when one region commits it, or after it replicates? Faster writes and global read-after-write pull in opposite directions.
What I would build first
- A stateless application with separate link-management and redirect modules.
- PostgreSQL as the source of truth, with Redis cache-aside on reads.
- Eight-character random Base62 codes, a unique constraint and retry on collision.
302redirects with an explicitCache-Controlpolicy.- Analytics as asynchronous events.
- Validation, rate limiting and moderation from day one.
- A written revocation budget, with each cache layer's settings derived from it.
Physical service separation, regional replicas, a distributed key-value store and edge redirect logic would come only after measured bottlenecks justify them.
The point of the exercise for me is that this order of reasoning transfers. Decide what must stay true when something goes wrong, here "a disabled link stops working within N seconds", and then check every performance layer against that budget.
References
- Alex Xu, System Design Interview – An Insider's Guide, Volume 1
- Martin Kleppmann, Designing Data-Intensive Applications
- RFC 9110: HTTP Semantics, redirection status codes
- RFC 9111: HTTP Caching
- RFC 3986: Uniform Resource Identifier (URI): Generic Syntax
- Michael T. Nygard, Release It!