Scale Interview
System DesignEasy

Design a URL Shortener

Bitly System Design

Overview

A URL shortener takes a long URL like https://example.com/articles/2026/system-design/the-very-long-slug?utm=campaign and returns a compact alias such as https://sho.rt/aZ4kPq9. When someone visits the short link, the service looks up the original URL and redirects the browser to it. Bitly, TinyURL, and the t.co links inside X are all variations on this idea.

It is labeled "Easy" because the core feature set is small, but it is a useful interview question because almost every important system-design lever shows up: read-heavy traffic, a key-generation problem with uniqueness constraints, caching, the SQL-vs-NoSQL decision, and a redirect-semantics tradeoff (301 vs 302) that determines whether you can collect click analytics. Treat the simplicity as a chance to explain the important parts clearly rather than adding many extra features.

In this walkthrough you will scope the problem, do rough estimation math, design the data model and API, draw the architecture, and then examine the two questions that separate a basic answer from a strong one: how you generate short codes without collisions, and how you serve billions of redirects with low latency.

Functional requirements

These are the things the system must do. Keep the list short and get the interviewer to agree before you build anything.

  • Shorten a URL. Given a long URL, return a unique short URL. Optionally accept a user-provided custom alias (e.g. sho.rt/my-launch).
  • Redirect. Given a short URL, redirect the client to the original long URL.
  • Optional expiration. A link may carry a TTL after which it stops resolving.

Explicitly out of scope (state this — scoping is graded): user accounts and auth, link editing, password-protected links, and a full analytics product. We will, however, still consider counting clicks, because the redirect-code decision depends on it.

Tip

A strong opening might sound like: "I'll focus on creating short URLs, redirecting users, optional expiration, and basic click counting. I'll leave accounts, link editing, password-protected links, and a full analytics dashboard out of scope unless you want to include them."

Naming a feature as out of scope is itself a positive signal — it shows product judgment. But do not spend too much time on the removed features; agree on the boundary with your interviewer and move on to the design.

Non-functional requirements

These are the qualities the system must have. For a URL shortener the dominant ones are:

  • Read-heavy, low latency. Redirects vastly outnumber creates, and a redirect sits in the user's critical path before the real page loads. Target p99 redirect latency under ~50 ms.
  • High availability for reads. A dead shortener breaks every link anyone has ever shared. Availability of the redirect path is more important than strong consistency — a newly created link being readable a second late is fine; an outage is not.
  • Uniqueness. Two long URLs must never collide onto the same short code (that would send users to the wrong place). The same long URL submitted twice may map to one code or two — a design choice, not a bug.
  • Unpredictability (mild). Short codes should not be easy to enumerate, or scrapers can try every possible code in the keyspace and collect many links.
  • Scalable & durable. Billions of links, stored for years, never silently lost.

Estimations

State your assumptions out loud, but keep the math lightweight. The interviewer cares more about how the numbers change your design than whether you divide perfectly by 86,400.

Note

In an interview, round aggressively. "About 1K writes/sec and 100K reads/sec" is usually enough; the goal is to justify design choices, not to prove exact arithmetic.

Rounded assumptions

  • New URLs created: ~100M / day → about 1K writes/sec.
  • Redirects are much more common than creates. With a 100:1 read:write ratio, expect about 100K reads/sec.
  • Retain links for 5 years. With URLs and metadata rounded to a few hundred bytes per row, storage lands around 100 TB.
  • A 7-character base62 code gives about 3.5 trillion possible codes, enough extra capacity for hundreds of billions of stored URLs.

That is enough math for the interview. Now say what the numbers imply.

How the numbers affect the design

Signal from the numbers Design decision
Traffic is read-heavy Optimize the redirect path first; creates can be slower.
Reads are around 100K/sec Use multiple stateless app servers behind a load balancer.
Most reads are repeated hot links Add a cache for short_code → long_url; otherwise the database takes every redirect.
Storage is around 100 TB Use a horizontally scalable store, or shard SQL explicitly. A single relational machine is not enough.
Hundreds of billions of rows Use at least 7 base62 characters; 6 characters runs out too soon.
Writes are only around 1K/sec A simple counter with range allocation can work; a KGS is useful if you also want random, non-enumerable codes.

Caution

Do not choose a 6-character code just because it looks short. Under these assumptions, 62⁶ gives only about 57B codes, which is not enough for 5 years of growth.

If the interviewer asks for more precision, you can show the rough arithmetic. Otherwise, move on. The important skill is connecting the estimate to the architecture.

Core entities / data model

There is essentially one entity:

Field Type Notes
short_code string (PK) 7-char base62, the lookup key
long_url string the destination
created_at timestamp for retention / debugging
expires_at timestamp? nullable; null = never expires
user_id string? optional owner (if accounts existed)

The access pattern is a single point lookup: given short_code, return long_url. That pattern drives the storage choice.

SQL vs NoSQL. Because there are no joins, no multi-row transactions, and the workload is a massive volume of key-based point reads, a horizontally partitioned key-value or wide-column store (DynamoDB, Cassandra) is the natural fit — short_code is the partition key, so lookups hit exactly one partition. A single relational instance cannot hold ~100 TB or serve ~100K reads/sec, so if you choose SQL you must shard it yourself.

Dimension SQL (sharded Postgres/MySQL) NoSQL (DynamoDB / Cassandra)
Access pattern fit Fine for point lookup by PK Excellent — built for KV point reads
Horizontal scaling Manual sharding & resharding pain Native, automatic
Schema flexibility Rigid (fine here — schema is stable) Flexible
Strong consistency Easy Tunable; default eventual
Operational cost More ops work at this scale Managed options reduce ops

For this problem NoSQL wins on operational simplicity at scale, but say clearly that either works because the access pattern is trivial — the decision is about scale and ops, not about the query.

Warning

Do not stop at "use SQL" without explaining scale. A single relational database machine cannot comfortably hold ~100 TB and serve ~100K reads/sec, so a SQL answer must include sharding or another horizontal scaling plan.

API design

Two endpoints carry the whole product. Keep them RESTful and let HTTP do the work.

Create

POST /api/urls
Body: { "long_url": "https://...", "custom_alias": "my-launch", "expires_at": "2027-01-01T00:00:00Z" }
201 Created
Returns: { "short_url": "https://sho.rt/aZ4kPq9" }

custom_alias and expires_at are optional. If a custom alias is already taken, return 409 Conflict.

Redirect

GET /{short_code}
302 Found
Location: https://example.com/the/original/url

The redirect endpoint lives at the root of the short domain (sho.rt/{code}), not under /api, so the links stay short. The choice of 301 vs 302 is a deliberate design decision covered in the deep dives — it is not cosmetic.

High-level architecture

Build the architecture in layers. For beginners, it is easier to understand the two product requirements separately before merging them.

1. Create short URL flow

The create path is the write path. The user gives us a long URL, we choose a unique short code, and we save the mapping.

flowchart LR
    User[User] -->|"POST /api/urls\nlong_url"| App[App Server]
    App -->|"generate unique code"| Code[Code Generator]
    Code --> App
    App -->|"save short_code -> long_url"| DB[(URL Store)]
    App -->|"return short URL"| User

Example: the user submits https://example.com/a/very/long/path. The app generates aZ4kPq9, stores aZ4kPq9 → https://example.com/a/very/long/path, and returns https://sho.rt/aZ4kPq9.

The code generator can be a counter, a key generation service, or a random generator with collision checks. That choice is the first deep dive.

2. Redirect flow

The redirect path is the read path. The browser already has a short URL and needs the destination as fast as possible.

flowchart LR
    Browser[Browser] -->|"GET /aZ4kPq9"| App[App Server]
    App -->|"check aZ4kPq9"| Cache[(Cache)]
    Cache -->|"hit: long_url"| App
    Cache -.->|"miss"| DB[(URL Store)]
    DB -.->|"long_url"| App
    App -->|"302 Location: long_url"| Browser

On a cache hit, the app can return the redirect immediately. On a cache miss, it reads the URL store, writes the result back into cache, then returns the redirect. This is called the cache-aside pattern.

This flow is where most of the system load lives. At ~100K reads/sec, the cache is not a bonus feature; it is what protects the database and keeps redirects fast.

3. Combined architecture

Now merge the two flows. A load balancer sends both creates and redirects to stateless app servers. The app servers talk to a code generator for creates, a cache for hot reads, and the URL store as the durable source of truth.

flowchart LR
    Client[Client / Browser] --> LB[Load Balancer]
    LB -->|"POST /api/urls"| App[App Servers]
    LB -->|"GET /short_code"| App
    App -->|"create: get code"| Code[Code Generator / KGS]
    App -->|"create: write mapping"| DB[(URL Store)]
    App -->|"redirect: check cache"| Cache[(Redis Cache)]
    Cache -->|"hit: long_url"| App
    Cache -.->|"miss: read mapping"| DB
    DB -.->|"populate cache"| Cache
    App -->|"short URL or 302 redirect"| Client

Because reads dominate by 100:1, the redirect path gets the most optimization. The create path mainly needs safe uniqueness. The read path needs low latency, high availability, and cache protection.

Deep dives

1. Generating the short code

This is the heart of the problem. Three strategies, in increasing order of robustness:

Strategy A — Random / hash and check (the naive approach) (situational)

Generate a random 7-char base62 string, or hash the long URL into bytes, base62-encode part of that hash, and take the first 7 characters. Either way, check the database for a collision before saving.

Example with a random code:

  1. User submits https://example.com/article.
  2. App randomly generates aZ4kPq9.
  3. App checks the URL store: "Does aZ4kPq9 already exist?"
  4. If no, save aZ4kPq9 → https://example.com/article.
  5. If yes, generate another code and repeat.

Example with a hash:

  1. Hash https://example.com/article into bytes. The same URL always produces the same hash.
  2. Base62-encode part of the hash output.
  3. Take the first 7 characters as the candidate code, for example m8QpL2x.
  4. Check whether m8QpL2x is already used.
  5. If it maps to the same long URL, return the existing short URL. (Returning the existing code is the "one long URL → one code" branch of the design choice noted in the requirements; a random generator would instead mint a second code for the same URL.)
  6. If it maps to a different long URL, try another candidate, such as hash(long_url + ":1"), hash(long_url + ":2"), or a longer prefix.
  • Pro: trivial to implement; codes are unpredictable.
  • Con: every create requires a read-before-write to check for collisions, which costs a round trip and gets worse as the table fills (more collisions, more retries). Hashing the URL also makes the same long URL always produce the same code, which can be a feature or a privacy leak depending on requirements. At ~1K writes/sec the extra read isn't fatal, but it's wasted work and the collision probability creeps up over time.
  • Verdict: works, with caveats — fine for a small system where the read-before-write cost is negligible, but it doesn't scale: the collision-check round trip is wasted work that gets worse as the table fills, so prefer a KGS once writes grow.

Caution

Do not jump straight to hashing without mentioning collision checks. If the hash-derived code already belongs to a different URL, you must retry with another candidate. Also mention the privacy issue: deterministic hashing can reveal that two users submitted the same URL.

Strategy B — Counter + base62 encoding (alternative)

Keep a global monotonically increasing integer counter. For each new URL, take the next integer and base62-encode it to produce the code. Fix the alphabet once so results are reproducible; this guide uses digits-first 0-9 A-Z a-z, under which integer 999999999"15ftgF". No collisions are possible by construction — every integer maps to exactly one code — so you eliminate the read-before-write entirely.

Example:

Counter value Base62 code
1,000,000 4C92
1,000,001 4C93
1,000,002 4C94

The database never has to ask "is this code already used?" because two requests never receive the same counter value.

One wrinkle: small integers base62-encode to short codes (1,000,0004C92, four characters), which contradicts the fixed 7-char code from the data model and estimations. Offset the counter so every code reaches full width — start it at 62⁶ and left-pad — which also removes the short, guessable early codes.

The beginner version is "one global counter." The production version is range allocation:

  • Server A reserves IDs 1,000,000 through 1,000,999.
  • Server B reserves IDs 1,001,000 through 1,001,999.
  • Each server generates codes locally until its range is exhausted, then asks for another range.

This avoids calling the counter service on every create request.

  • Pro: zero collisions, no collision check, simple.
  • Con (1): a single counter is a contention bottleneck and a single point of failure. The standard fix is to hand each app server a range of IDs at a time from a coordinator (e.g. ZooKeeper or a row in a strongly-consistent store) — server A gets 1–1,000, server B gets 1,001–2,000 — so servers mint codes locally and only coordinate occasionally. The hand-out itself must be a single atomic compare-and-set that advances the counter (e.g. atomically bump the reserved-up-to value), so two servers can never be handed the same range. Some ranges go unused when a server dies, which is fine because the underlying counter is a 64-bit integer with vastly more values than we will ever mint.
  • Con (2): sequential integers produce sequential, enumerable codes — a scraper can guess ...aZ4kPq8, ...aZ4kPq9. A shuffled or secret alphabet does not fix this: an attacker walks the output space directly (aaa…, aab…) without knowing the alphabet, because consecutive integers still land in a dense, walkable range. The real fix is a reversible permutation of the ID space — a Feistel network, a small block cipher, or multiply-by-a-coprime mod 62⁷ — so consecutive IDs scatter across the entire keyspace before encoding. Encode the scrambled integer, not the raw counter. (A large sparse random or KGS keyspace also helps.)
  • Verdict: strong and simple; the contention is solved with range allocation, the predictability with a reversible permutation of the ID space (a scrambled alphabet alone does not prevent enumeration).

Warning

Do not propose one global counter and stop there. At scale, that counter is both a bottleneck and a single point of failure unless you add range allocation and replication.

Strategy C — Key Generation Service (KGS) — the best at scale

Run a standalone service that pre-generates random unique 7-char keys offline and stores them in two tables: unused and used. App servers request a batch of keys from the KGS, mark them used, and hand them out. Generation and uniqueness checking happen out of the request path.

Example:

Unused keys: aZ4kPq9, N7bQa10, x2LmR8s, ...
Used keys:   B8pLm2c, q1Xa9Rt, ...

When an app server needs keys, it asks the KGS for a small batch:

  1. App server requests 1,000 unused keys.
  2. KGS atomically moves those keys from unused to used or marks them reserved. This claim is a single conditional operation (an atomic move / compare-and-set from unused→used), so two servers requesting keys at the same moment can never both be handed the same key — the race is closed at the KGS, not merely assumed.
  3. App server keeps the batch in memory.
  4. Each create request takes one key from the local batch and writes the URL mapping.

The important interview idea: the expensive uniqueness work happens before the user request. The create request only consumes a known-good key.

  • Pro: create requests never do a collision check at all — keys are guaranteed unique and already minted. Codes are random (not enumerable). Throughput on the create path is excellent because the expensive work is amortized offline.
  • Con: the KGS is a new service to operate and a potential single point of failure (run it replicated). You must atomically move keys from unused → used so the same key isn't handed to two servers; keys loaded into a server that then crashes are lost (again, fine given the keyspace). It is more moving parts than a counter.
  • Verdict: the cleanest answer for a large-scale, unpredictable-code requirement. In an interview, present the counter as the simple baseline and KGS as the scale-up — naming the read-before-write problem and how each strategy avoids it is what earns the credit.
Strategy Collision check on write Predictable codes Bottleneck Best when
Random / hash Yes (every write) No (random) Collision retries Small scale, fastest to implement
Counter + base62 No Yes (mitigable) Counter contention Want simplicity, can permute IDs
KGS No No Extra service Large scale + unpredictability

2. Serving reads: caching and the redirect

At ~100K reads/sec, the redirect path must avoid hitting the database for every click. The usual design is a layered read path: try the closest cache first, then the app cache, and only use the database when both caches miss.

flowchart LR
    Browser[Browser] -->|"GET /aZ4kPq9"| CDN[CDN / edge cache]
    CDN -->|"hit: cached 302"| Browser
    CDN -.->|"miss"| App[App Server]
    App -->|"lookup short_code"| Redis[(Redis cache)]
    Redis -->|"hit: long_url"| App
    Redis -.->|"miss"| DB[(URL Store)]
    DB -.->|"long_url"| App
    App -.->|"write short_code -> long_url"| Redis
    App -->|"302 Location: long_url"| CDN
    CDN --> Browser

Read the diagram in this order:

  1. The browser requests https://sho.rt/aZ4kPq9.
  2. A CDN or edge cache may already have the redirect response. If yes, it can return 302 Location: long_url without calling your app.
  3. If the CDN does not have it, the request reaches an app server.
  4. The app checks Redis, keyed by short_code → long_url.
  5. If Redis has the value, the app returns the redirect immediately.
  6. If Redis does not have the value, the app reads the URL Store, writes the value back into Redis, then returns the redirect. This is the cache-aside pattern.

The reason this works is traffic concentration. With a Pareto traffic distribution, a small number of popular links may receive most requests. Caching those popular links can make the cache hit rate higher than 80%, so the database only handles cold or rarely used lookups.

Use an LRU eviction policy so popular links stay in cache. Also set the cache TTL at or below the link's expires_at, so expired links do not resolve from stale cache entries.

The CDN layer is optional but useful. It serves cacheable redirect responses from a POP near the user, which reduces network latency. A 302 is only shared-cacheable if you attach an explicit short Cache-Control TTL, and caching it trades away some analytics fidelity — the very thing 302 was chosen to preserve — in exchange for edge latency, since clicks served from the POP never reach your app until the TTL lapses. The challenge is invalidation: if the destination changes or the link expires, cached redirect responses must not stay valid for too long. This connects directly to the next decision: 301 vs 302.

Caution

Do not treat the cache as optional in a read-heavy design. Without Redis or another cache, the database handles every redirect, including viral links, and becomes the bottleneck.

3. 301 vs 302 — the redirect-semantics tradeoff

  • 301 Moved Permanently: browsers and intermediaries cache the redirect aggressively. Subsequent clicks on the same link skip your server entirely and jump straight to the destination. That is great for latency and load — but it means you can no longer count clicks. You can still change or expire the mapping server-side; the problem is that clients and intermediaries that already cached the 301 skip your server entirely, so they never see the update or expiry.
  • 302 Found (temporary): the client comes back to your server on every click. You pay the round trip, but you can record analytics, enforce expiration, and update the destination.

The right choice depends on whether click analytics matter. Since most real shorteners (Bitly's entire business) depend on counting clicks, 302 is the usual answer — you accept the extra hop to keep the link "live" and observable. State this tradeoff explicitly; interviewers specifically listen for it.

Warning

Do not choose 301 only because the URL "permanently" points somewhere. Browser and CDN caching can prevent your service from seeing later clicks, which breaks reliable analytics and makes expiration or destination changes harder.

Tradeoffs & bottlenecks

  • Cache stampede on hot keys. If a viral link expires from cache, thousands of concurrent misses can hammer the database for the same key. Mitigate with request coalescing (single-flight) or by serving slightly stale values while one request refreshes.
  • Counter contention vs. lost ranges. Range allocation removes the single-counter bottleneck but wastes ID ranges when servers crash; because the counter is a 64-bit integer with far more values than we will ever mint, that waste is irrelevant. Choosing this tradeoff knowingly is the point.
  • 301 latency/load vs. 302 analytics. Covered above — the central product tradeoff. You can either cache the redirect and make it very fast, or send each click through your service so you can measure it.
  • Strong consistency vs. eventual consistency. A link being readable shortly after creation is acceptable, so eventual consistency on reads is fine — but custom-alias creation must be strongly consistent to avoid handing the same alias to two users (enforce uniqueness at write time, return 409 on conflict).
  • Hot partitions. If short_code is the partition key and one code goes viral, that partition gets hot; the cache (and CDN) protects the store, which is why cache design is required, not optional.
  • Single points of failure. The KGS, the ID-range coordinator, and the cache layer all need replication. The redirect path must degrade gracefully — on a cache outage, reads fall back to the store, which should be provisioned to survive that surge.

Extensions if asked

If the interviewer wants to push beyond the core URL shortener, add only the extension that changes the design discussion:

  • Click analytics. Keep redirects fast by emitting click events asynchronously to a queue or stream, then aggregate them offline. Do not synchronously write an analytics row before every redirect.
  • Abuse prevention. Add URL validation, malware/phishing checks, create-rate limits, and takedown tooling. A public shortener will be abused if every submitted URL is accepted blindly.
  • Custom aliases and ownership. If users can claim human-readable aliases, enforce uniqueness with a conditional write or strongly consistent transaction and return 409 Conflict on collision.
  • Privacy. Deterministic hashing can reveal that two users submitted the same long URL. If privacy matters, prefer random/KGS-generated codes or per-user ownership semantics. There is no separate privacy guide yet; this is a good follow-up topic if the interviewer asks about abuse, user accounts, or private links.

What interviewers look for & common mistakes

What interviewers usually reward:

  • Tight scoping. Cutting accounts/editing/analytics-product up front and getting agreement, while keeping click-counting in scope because it drives the 301/302 call.
  • Consistent estimation math. Deriving ~100K reads/sec and ~100 TB from stated assumptions, and justifying the 7-char code from 62⁷ ≈ 3.5 trillion vs. hundreds of billions of rows.
  • Naming the read-before-write problem in code generation and showing how the counter/KGS approaches avoid it.
  • Caching as the centerpiece of a 100:1 read-heavy system, with hit-rate reasoning.
  • The 301 vs 302 tradeoff stated explicitly with the analytics consequence.

Before you finish, do a quick mistake check:

  • Did you justify the code length from the expected number of rows?
  • Did you explain how your code-generation strategy avoids or handles collisions?
  • Did you make the redirect path cache-first?
  • Did you scale the data store horizontally, or explain why your SQL choice is sharded?
  • Did you state the 301 vs 302 analytics tradeoff?
  • Did you remove single points of failure from the counter/KGS/cache path?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →