A URL shortener is a hash table with a domain name. That is the joke, and it is why the problem is a good one: the feature list fits in a sentence, so every hour you spend on it is spent on the parts that actually decide whether a system works.
Two endpoints. Create a short code for a URL. Follow a short code to a URL. Let us build it properly.
What are we actually building?
Start with the numbers, because they change the answers.
Back of the envelope, before any code
- Writes / second
- —
- Reads / second
- —
- Peak reads / second
- —
- Storage after 5 years
- —
Three things fall out of that panel, and they set everything that follows.
The data is small. Even at a million new links a day, five years of rows fit comfortably on one machine. There is no sharding problem here, and any design that starts with sharding has skipped the basics.
Reads dwarfs writes. A hundred to one is a modest ratio for this workload. Every engineering decision should be biased towards making the read path boring.
The cache is the architecture. Watch what happens to the database load as the cache hit rate moves from 80% to 95%. That single number matters more than your choice of database.
How do we generate the codes?
This is the only genuinely interesting design decision in the system, and there are three defensible answers.
Option 1: encode a counter
Keep an auto-incrementing integer, encode it in base62, and you are done.
One integer, one short code
The division
| Code length | Distinct URLs |
|---|---|
| 1 | 62 |
| 2 | 3,844 |
| 3 | 238,328 |
| 4 | 14,776,336 |
| 5 | 916,132,832 |
| 6 | 56,800,235,584 |
| 7 | 3,521,614,606,208 |
It is fast, it never collides, and the codes are as short as they can possibly be.
Though there are some problems with it. Codes are guessable — 4c92 tells you 4c93 probably
exists, so anything private is enumerable. Codes leak your growth rate, which is a
business-intelligence gift to competitors. And a single global counter is a coordination
point, which is the thing you were trying to avoid.
The counter problem has a simple fix: hand each application instance a range of ids — say a million at a time — from a small allocator, and let it burn through them locally. One coordination round trip per million links, no shared state on the hot path, and gaps in the sequence that does not affect the system.
Option 2: random keys
Generate seven random base62 characters and check whether they are taken.
How soon does a random key collide?
Codes are unguessable, and there is no counter to coordinate. The catch is the birthday problem, and the panel above puts numbers on it — collisions occurs at roughly the square root of the key-space, not near the end of it.
This however, does not make random keys wrong. It makes the unique index on the code column mandatory, and the insert becomes a retry loop instead of a single statement. As long as the key-space is large enough that retries are rare, this is a perfectly good scheme, and it is what most public shorteners use.
Option 3: hash the URL
Take a SHA-256 of the target and use the first seven base62 characters.
It is deterministic, which means submitting the same URL twice gives the same code and saves a row. It also means you cannot ever give two people different codes for the same target, which breaks per-user analytics, and hash collisions still need handling. Useful as a deduplication layer, not as your primary scheme.
What to actually do: ranged counters encoded in base62, with a random component mixed in if guessability matters. You get the short codes and no coordination on the hot path, and you keep the option of unguessable codes for the customers who ask.
What does a request look like?
Two paths, wildly different requirements
- 1POST /api/linksAuthenticated, rate limited per account
- 2ValidateScheme is http or https, host resolves, not on a blocklist, not a shortener loop≈2 ms
- 3Allocate a codeTake the next id from a range this instance already reserved, encode base62≈0 ms
- 4INSERTUnique index on the code; on conflict, take the next id and retry≈3 ms
- 5201 CreatedReturn the short URL. Nothing is cached — nobody has asked for it yet
Writes are rare and can afford to be careful. Validation, blocklists and rate limits all belong here, because this is the only path an abuser can use to put something into your system.
- 1GET /4c92Anycast to the nearest edge location≈15 ms
- 2Edge cacheA hit here answers without touching your infrastructure at all≈1 ms
- 3Key-value lookupOn a miss: read the code from a replicated store, not the primary database≈5 ms
- 4301 or 302302 unless you never intend to change the target — a 301 is cached by browsers forever and takes your analytics with it
- 5Click eventWritten to a queue after the response, never before itasync
Reads are the entire load. Every decision here is about doing less: cache at the edge, read from a replica, and move analytics off the response path so a slow queue can never slow a redirect.
Two details on that read path repay attention.
301 or 302? A 301 Moved Permanently response is cached by browsers indefinitely,
which is ideal for latency but terrible for everything else, you can never change
the target, and you never see the click again, so the analytics go to zero.
A 302 Found response costs you a request every time and keeps both. Default to 302 and
offer 301 as an option for links that are genuinely permanent.
Analytics must be asynchronous. Writing a click row before responding puts your analytics database on the critical path of every redirect. Emit an event to a queue, or batch in memory and flush, and let the redirect return. A shortener that is down because its stats table is locked is an embarrassing way to fail.
Which database?
Almost anything, which is the honest answer for a workload this shape.
The access pattern is a primary key lookup returning one row. Postgres does that in under a millisecond at this scale, and gives you transactions, a unique index, and the ability to run a report without exporting anything. Start there.
The refinement is to separate the two jobs. Keep the durable record — code, target, owner, timestamps, options — in Postgres. Serve reads from something in front of it: Redis for a single region, or a replicated key-value store like DynamoDB, Cloudflare KV or Workers KV if the traffic is genuinely global. The mapping is immutable once created, which makes caching trivial: no invalidation logic, just a long TTL and a delete on the rare occasions someone edits or removes a link.
If you do reach a scale that needs sharding, shard on the code itself — it is the only key anything looks up by, and it is uniformly distributed by construction.
What breaks when real people find it?
Everything on this list has taken down a real shortener.
It becomes a phishing tool. Somebody will shorten a link to a credential-harvesting page and mail it to ten thousand people, and the domain that gets blacklisted is yours. Check submissions against Google Safe Browsing or a similar feed, at creation and again on a schedule, because a URL that was clean on Tuesday can become malicious by Friday.
It becomes an open redirect. Your domain, its destination. That is a redirect gadget attackers actively look for, so it is worth an interruption for links that have not been vetted, and it is worth never allowing a shortener to point at itself.
Someone shortens the shortener. Two loops pointing at each other will happily consume a request handler. Resolve one hop at creation, refuse anything that lands back on your own domains, and cap redirect chains.
A single link goes viral. One code takes ninety per cent of your traffic. This is the case a cache handles beautifully and a database does not, and it is the reason the edge cache is not an optimization but the design.
Enumeration. Sequential codes get scraped, and now somebody has a list of every URL your users have shortened, including the ones they assumed were private. Rate limit by IP on the redirect path, and use unguessable codes wherever the target is not public.
Custom aliases collide with your routes. The moment you let a user pick
cbz.to/pricing, they can pick cbz.to/api or cbz.to/admin. Reserve a namespace
before you launch the feature, not after.
What would we build on a Monday?
For a real product, this is the shape we would ship:
- Postgres for the durable record:
codeprimary key,target,owner_id,created_at,expires_at,is_active. - Ranged id allocation, a million at a time, encoded in base62, with a random suffix on links flagged private.
- A cache in front of every read — an edge cache first, a key-value store behind it, the database only on a miss. Immutable data, so a long TTL and an explicit delete.
- 302 by default, 301 opt-in per link.
- Clicks to a queue, aggregated on a schedule. Never on the response path.
- Safe Browsing at creation and on a weekly sweep, plus per-account rate limits and a reserved-prefix list.
That is a system one engineer can build in a week and that will hold a billion links without a redesign. The interesting part was never the hash table — it was noticing that reads and writes are different systems that happen to share a table.