URL Shortener System Design: From Short Codes to Scale
Design a URL shortener with capacity estimates, Base62 IDs, collision handling, redirect caching, editable links, and asynchronous analytics.
In this article
A URL shortener looks like a dictionary: store a long URL under a short code, then look it up. The interesting design begins when a link goes viral, its owner changes the destination, or the service must disable it quickly. Now a tiny redirect sits at the intersection of caching, unique IDs, availability, and control over stale data.
Let’s build a concrete design for a service with editable links and optional click analytics. The example scale below is a planning exercise, not a description of Bitly’s infrastructure.
Define the product before choosing the code
Our initial scope supports:
- Creating a short link for an HTTP or HTTPS destination.
- Redirecting
GETrequests from a short code to the current destination. - Letting authenticated owners edit, expire, or disable their links.
- Collecting best-effort click analytics with stated limitations.
Custom domains, vanity aliases, private links, and strict billing-grade analytics can come later. Reserve application routes such as /api and /login so generated or custom codes cannot collide with service endpoints.
The central invariant is simple: a code identifies at most one mapping within its namespace. If custom domains are introduced, the namespace may be the pair of domain and code rather than the code alone.
Estimate the load with explicit assumptions
Assume 10 million new links and one billion redirect requests per day. Let the average stored mapping use 500 bytes, including its destination and basic metadata but excluding indexes, replication, and storage-engine overhead.
- Average redirects
- 11.6k/s
- One billion requests divided by 86,400 seconds.
- Read/write ratio
- 100:1
- One billion redirects to ten million new links.
- One year of mappings
- 1.8 TB
- Raw decimal storage at 500 bytes per new link.
Creation averages about 116 requests per second. A chosen 10× redirect peak would be roughly 116,000 requests per second. Real peaks must come from product evidence; an arbitrary multiplier is only a scenario to test.
Storage arithmetic is bytes for one year. Copies, indexes, backups, and analytics add more. Enforce a destination-length limit or a small number of enormous URLs can invalidate the average-size assumption.
Separate the write path from the redirect path
The management API validates ownership and destinations, creates mappings, and processes edits. Redirect workers do one lookup and produce a small response. They should not require an analytics database query before sending the visitor onward.
These can start as separate logical routes in one application. Different deployment pools become useful when independent scaling or failure isolation justifies them. Draw a boundary because it changes ownership or operations, not simply because microservices look impressive.
For a missing cache entry, the durable mapping store remains authoritative. Cache state can be rebuilt. Limit concurrent origin lookups so cache failure does not send the full redirect load straight to an undersized database.
Choose an ID strategy you can defend
Base62 represents values using digits and letters. It is an encoding, not a uniqueness algorithm or an encryption scheme. Eight characters provide possible strings if the namespace uses all combinations.
| Strategy | How uniqueness is obtained | Advantage | Tradeoff |
|---|---|---|---|
| Allocated integer, encoded as Base62 | Unique ID allocation | Compact and collision-free within the allocator's contract | Allocation coordination and predictable identifiers |
| Random code plus conditional insert | Database rejects duplicate codes | Independent generation by many writers | Collisions require bounded retries |
| Hash of destination plus collision handling | Store verifies whether the code is available | Can support deliberate deduplication | Truncation collides; identical URLs may need different owners or settings |
For this design, choose random eight-character codes and a unique primary key. Use an appropriate random generator; do not generate codes from a timestamp alone.
At one billion occupied codes, a fresh uniformly random candidate has roughly a chance of hitting an occupied code: about 0.00046%. That small probability does not justify skipping the uniqueness check. Across a billion initial insertions, the birthday approximation predicts roughly 2,290 duplicate draws before accounting for retries. These are calculations under a uniform-random model, not observed collision rates.
Let storage settle concurrent creation
A check-then-insert sequence is unsafe: two writers can both observe that a code is free. Use a uniqueness constraint or conditional write that arbitrates the insert at the authoritative store.
INSERT INTO short_links
(code, destination, owner_id, version, created_at)
VALUES
($1, $2, $3, 1, CURRENT_TIMESTAMP)
ON CONFLICT (code) DO NOTHING
RETURNING code;This PostgreSQL example assumes code has a unique constraint. If it returns no row because the code already exists, choose another candidate and retry up to a defined bound. Other database failures need their own handling; a connectivity error is not a collision. See the PostgreSQL INSERT reference for the conflict-handling semantics.
The creation API also needs request idempotency if retries should return the original short link. A client losing the success response should not necessarily create a new link and a second analytics identity. Store the request key and result under the same durable operation as the mapping, according to the selected database’s transaction capabilities.
Keep the mapping small and versioned
| Field | Why it exists |
|---|---|
code | Unique lookup key in the chosen namespace |
destination | Validated target URL |
owner_id | Authorizes edits and deletion |
version | Distinguishes old and new mapping representations |
created_at | Supports lifecycle and operational inspection |
expires_at | Defines when the redirect stops being valid |
disabled_at | Represents an explicit administrative or owner disable |
Avoid recycling disabled codes by default. An old email, bookmark, or cached page could otherwise start sending visitors to a different owner’s destination.
Choose redirect semantics and cache policy together
For editable public links requested with GET, this design starts with 302 Found. A temporary redirect communicates that the destination may change. If preserving the method across a temporary redirect is required, consider 307; permanent counterparts include 301 and 308. The method and permanence semantics come from RFC 9110.
The status alone is not a complete cache policy. A 302 response can still be cacheable when the response explicitly permits it. For the initial policy, prevent HTTP caches from storing the redirect while retaining an internal mapping cache:
HTTP/1.1 302 Found
Location: https://example.com/guides/distributed-systems
Cache-Control: no-storeThis keeps browsers and intermediary caches from reusing a compliant stored redirect response. It does not disable the application’s own Redis mapping cache, and it does not erase a destination someone has already seen. HTTP storage and freshness directives are defined in RFC 9111.
| Product priority | Possible HTTP policy | Architectural implication |
|---|---|---|
| Editable links with controlled redirects | Temporary redirect and no-store | More traffic reaches the service; internal cache freshness still matters |
| Public links accepting brief staleness | Explicit short shared-cache freshness | Edge hits reduce origin load; edits need expiry or purge handling |
| Truly permanent destinations | Permanent redirect with deliberate caching | Future retargeting is difficult once clients reuse stored redirects |
An edge-cache policy must be chosen with the disable requirement. You cannot promise immediate global revocation while also allowing long-lived independent cached redirects without an effective revocation mechanism.
Handle edits, hot links, and cache misses
For an edit, commit the new destination and version, then invalidate cached mappings. A delayed reader can still repopulate an older version after invalidation, so the design must either tolerate bounded stale behavior or coordinate version-aware fills. The caching strategy guide walks through that race.
For expiration, check expires_at on the read path even if background deletion has not run. Expiration is a product rule; physical cleanup is maintenance. Do not return a redirect merely because an expired record still exists in storage.
A single viral code creates a hot key. Hash partitioning spreads different codes, but all reads for this one code still map to the same partition. Local hot-key copies or edge caching can reduce that pressure when the freshness contract permits them. Bound local cache size and lifetime, and plan for coordinated refresh rather than simultaneous misses across every worker.
Unknown-code traffic needs separate attention. Short-lived negative caching helps repeated misses, but randomly generated misses can have little reuse. Rate controls, bounded origin concurrency, and detection of abusive request patterns protect the lookup store.
Keep analytics honest and off the critical path
A redirect is not necessarily a human click. Bots, preview generators, retries, and security scanners can all request links. Cached redirects can also bypass your origin entirely.
Our starting contract accepts best-effort event delivery. Use a bounded queue or buffer and record dropped events when it fills. If analytics must be durable, acknowledge a durable event handoff and include that latency and dependency in the design. Do not imply exact click accounting while silently dropping events.
Where available, give an event an ID for deduplication and record the measurement point: origin request, edge request, or another defined event. The reporting pipeline should label its semantics and delay. A marketing dashboard and a billing ledger need different guarantees.
Validate destinations without creating a fetch service
Allow only the schemes the product supports, parse the destination with a real URL parser, reject malformed input, and apply length and ownership checks. If the product later fetches previews or scans destinations server-side, that introduces a separate SSRF boundary involving redirects, DNS resolution, and private address ranges. OWASP’s SSRF prevention guide covers that server-side fetching problem.
A redirect-only service and a server-side preview fetcher have different security responsibilities. Include reporting and disable workflows for abusive links as product features rather than assuming validation can prove every destination harmless.
Frequently asked questions
Why not hash the long URL and take the first eight characters?
You can, if you handle collisions and deliberately want that deduplication behavior. Different owners may need different expiration, destinations, or analytics for the same original URL. Truncated hashes do not remove the uniqueness check.
Should I use SQL or NoSQL for the mapping store?
Both can serve key-based lookups. Evaluate atomic uniqueness, idempotent creation, edit consistency, replication, hot-key behavior, and operational cost. Start with a concrete engine and access pattern; the category label does not determine the answer.
Does a 301 redirect make the shortener faster?
A cached permanent redirect can avoid later service calls, but that changes editability and where analytics are measured. Choose the permanence and cache contract first; latency is only one part of the decision.
Test the promises behind the diagram
Force two writers to generate the same code. Edit a link while a cache fill is delayed. Send a burst to one viral code. Disable analytics. Request an expired link before cleanup runs. For each case, explain whether the visitor redirects and which durable fact decides the result.
Continue with SQL vs NoSQL, caching strategies, and rate limiter design. For a visual foundation, explore CDNs, consistent hashing, and idempotency.