System Design: Design a URL Shortener (TinyURL) Step by Step
Design a URL shortener like an interview answer: estimates, API, database, base62 short codes vs hashing, 301 vs 302 redirects, caching, sharding and abuse prevention.
New20 min read

To design a URL shortener like TinyURL or Bitly, you build two paths: a small write path that turns a long URL into a unique short code, and a very fast, heavily cached read path that redirects every click. Interviewers love to ask candidates to design URL shorteners because the problem is small enough to finish in 45 minutes yet touches almost every core system design idea: estimation, ID generation, data modeling, caching, redirects, sharding and abuse. This post walks through the whole design the way you would present it in an interview, step by step, with the numbers worked out and the trade-offs named.
You will get the requirements and estimates, the API and database design, four ways to generate short codes compared (hash, counter with base62, key generation service, Snowflake-style IDs), 301 versus 302 redirects and click analytics, caching, scaling, custom aliases, expiry and abuse prevention. There is a runnable Java base62 encoder with real output and a Spring Boot redirect endpoint.
If you have not seen our general method yet, the system design interview framework explains the 7 steps and time budget this walkthrough follows.
In this guide
- Step 1: Requirements
- Step 2: Back-of-the-envelope estimates
- Step 3: API design
- Step 4: URL shortener database design
- Step 5: High-level design
- Short URL generation: four options compared
- Redirects: 301 vs 302 and click analytics
- Caching hot links
- Scaling deep dive
- Custom aliases, expiry and deletion
- Preventing abuse
- What interviewers look for
- Follow-up questions to expect
- Practice with the full question bank
- Related reading
- FAQ
Step 1: Requirements
Start by agreeing on what the service must do. Spend about five minutes here and write the list on the board.
Functional requirements
- Given a long URL, return a short URL such as
https://s.example/4c92xQ1. - Visiting the short URL redirects to the long URL.
- Optional custom alias (
/summer-sale) and optional expiry date. - Basic click analytics per link: total clicks, clicks per day, top referrers.
Non-functional requirements
- Redirects are fast: a few milliseconds of server time, because they sit in front of every click.
- Highly available: a broken redirect breaks every place the link was shared.
- Short codes are unique forever, and ideally not easy to guess in sequence.
- Analytics can lag by a few seconds or minutes; redirects must not wait for them.
Out of scope unless the interviewer wants it: user accounts and billing, link editing UI, QR codes.
What the interviewer is checking: do you notice that this is a read-heavy system where the redirect path matters far more than the create path, and that analytics should be asynchronous?
Step 2: Back-of-the-envelope estimates
State your assumptions out loud. A common set: 100 million new links a month, and 100 redirects for every link created (a 100 to 1 read to write ratio). Keep links for 5 years.
- Write QPS: 100 M ÷ (30 × 86,400 s) ≈ 100 M ÷ 2.6 M ≈ 40 writes a second on average, about 100 at peak.
- Read QPS: 40 × 100 ≈ 4,000 redirects a second on average, about 10,000 at peak.
- Total links: 100 M × 12 months × 5 years = 6 billion.
- Storage: about 500 bytes per link with metadata and index overhead, so 6 B × 500 B ≈ 3 TB over five years.
- Cache: 4,000 × 86,400 ≈ 350 M redirects a day. Caching the hottest 20% of that volume is at most 0.2 × 350 M × 500 B ≈ 35 GB, and much less in practice because popular links repeat. That fits in a small Redis cluster.
- Code length: base62 gives 62^6 ≈ 56.8 billion six-character codes and 62^7 ≈ 3.5 trillion seven-character codes. Six characters would cover 6 billion links, but seven gives room for growth and for scrambling the IDs, so pick 7.
What the numbers tell you: writes are tiny, a single database primary can take them. Storage is moderate, a few terabytes. The redirect path is where the load is, so caching and a lean redirect service get most of the design attention.
Step 3: API design
Three endpoints are enough. Create a link:
POST /api/links
{
"url": "https://www.example.com/blog/2026/10/a-very-long-path?utm_source=newsletter",
"alias": "summer-sale",
"expiresAt": "2027-01-31T23:59:59Z"
}
201 Created
{
"code": "summer-sale",
"shortUrl": "https://s.example/summer-sale",
"expiresAt": "2027-01-31T23:59:59Z"
}GET /{code}returns302 Foundwith aLocationheader, or404 Not Found(or410 Gonefor an expired link, if you want to tell the difference).GET /api/links/{code}/statsreturns click counts for the owner.DELETE /api/links/{code}disables a link (owner only).
Mention that the create endpoint needs authentication or at least an API key and a rate limit, which matters again in the abuse section. The redirect endpoint is public and must stay as cheap as possible.
Step 4: URL shortener database design
There is one main access pattern: look up a long URL by its code. Everything else (list my links, stats) is secondary. That makes the code the primary key.
CREATE TABLE link (
code VARCHAR(16) PRIMARY KEY,
long_url TEXT NOT NULL,
owner_id BIGINT,
is_custom BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
CREATE INDEX link_owner_idx ON link (owner_id, created_at DESC);Click analytics do not belong in this table. Updating a counter row on every redirect would turn a read-heavy system into a write-heavy one and create hot rows for viral links. Clicks go to a separate event stream and an analytics store, covered below.
SQL or NoSQL for a URL shortener?
Both work, and saying why is what earns the points. The data is a simple key-value mapping with no joins, which suits a key-value store such as DynamoDB or Cassandra, and those scale out without manual sharding. A relational database such as PostgreSQL gives you a unique constraint on the code for free, which matters for custom aliases, and 3 TB over five years is manageable with sharding later. A reasonable interview answer: "PostgreSQL to start, sharded by code when one node is not enough; a key-value store is a good alternative if the team already runs one."
One trap worth mentioning: base62 codes are case-sensitive, so aB3 and Ab3 are different links. PostgreSQL compares VARCHAR values case-sensitively by default, but MySQL's default collation is case-insensitive, so in MySQL the code column needs a binary collation such as utf8mb4_bin.
Step 5: High-level design
Split the system into a write path and a read path, because their load differs by a factor of 100.
- Write path: the write API validates the URL, gets the next ID from its local block of IDs (more on this below), encodes it in base62, inserts
(code, long_url)into the database and returns the short URL. - Read path: the redirect service looks up the code in Redis, falls back to the database on a miss and fills the cache, returns a
302, and publishes a click event to Kafka without waiting. - Analytics: workers consume click events, aggregate counts per link per day, and write them to an analytics store that the stats endpoint reads.
Both services are stateless, so they scale horizontally behind the load balancer. In practice they can be one deployable with two endpoints at first; separating them later lets you scale the redirect side independently.
Short URL generation: four options compared
This is the deep dive most interviewers want, because it is where the real trade-offs are. There are four common approaches.
Option 1: Hash the long URL and truncate
Compute MD5 or SHA-256 of the long URL, encode it in base62 and keep the first 7 characters. The same long URL always gives the same code, which deduplicates for free, and no coordination between servers is needed.
The problem is collisions. Seven base62 characters give about 3.5 trillion values, which sounds like plenty, but the birthday problem says collisions start long before the space is full. The expected number of colliding pairs is roughly n² / (2 × space). For 100 million links that is about 1,400 collisions; for 6 billion it is about 5 million (the program below prints these numbers). Each write therefore needs a "does this code exist?" check and a retry with a salt, which adds a database read to every write and a race between two writers. It works, but it is the weakest option at scale.
Option 2: A counter encoded in base62
Give every new link a unique integer and encode it in base62. Unique integers mean no collisions and no existence check, and the codes are as short as they can be. Base62 uses the 62 characters 0-9, a-z and A-Z, all of which are safe in a URL path. Encoding is repeated division by 62.
Here is a complete encoder and decoder in Java, with a fixed-width version, a way to hide the sequence, and the collision math from option 1:
import java.math.BigInteger;
public class Base62 {
private static final String ALPHABET =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final int BASE = ALPHABET.length(); // 62
static String encode(long id) {
if (id < 0) throw new IllegalArgumentException("id must be >= 0");
if (id == 0) return "0";
StringBuilder sb = new StringBuilder();
while (id > 0) {
sb.append(ALPHABET.charAt((int) (id % BASE)));
id /= BASE;
}
return sb.reverse().toString();
}
static long decode(String code) {
long id = 0;
for (char c : code.toCharArray()) {
int digit = ALPHABET.indexOf(c);
if (digit < 0) throw new IllegalArgumentException("not base62: " + c);
id = Math.multiplyExact(id, BASE) + digit;
}
return id;
}
// Fixed-width code: left-pad with '0' so every code has the same length
static String encode7(long id) {
String s = encode(id);
return "0".repeat(Math.max(0, 7 - s.length())) + s;
}
// Scramble a sequential counter inside the 7-char space so codes are not guessable.
// id * A mod 62^7 is a bijection when gcd(A, 62^7) == 1 (A odd and not a multiple of 31).
static final long SPACE = 62L * 62 * 62 * 62 * 62 * 62 * 62;
static final long A = 2_176_477_521_915L; // about 0.618 * 62^7
static final long A_INV = BigInteger.valueOf(A).modInverse(BigInteger.valueOf(SPACE)).longValue();
static long scramble(long id) { return mulMod(id, A); }
static long unscramble(long x) { return mulMod(x, A_INV); }
static long mulMod(long a, long b) {
return BigInteger.valueOf(a).multiply(BigInteger.valueOf(b)).mod(BigInteger.valueOf(SPACE)).longValue();
}
public static void main(String[] args) {
System.out.println("62^6 = " + String.format("%,d", SPACE / 62));
System.out.println("62^7 = " + String.format("%,d", SPACE));
System.out.println();
for (long id : new long[] {0, 61, 62, 125, 1_000_000, 3_521_614_606_207L}) {
String code = encode(id);
System.out.printf("%,22d -> %-8s -> %,d%n", id, code, decode(code));
}
System.out.println();
System.out.println("Sequential ids, plain vs scrambled:");
for (long id = 1_000_000; id <= 1_000_003; id++) {
long s = scramble(id);
System.out.printf(" id %,d plain %s scrambled %s back %,d%n",
id, encode7(id), encode7(s), unscramble(s));
}
System.out.println();
// Truncated-hash approach: expected collisions ~ n^2 / (2 * space) (birthday bound)
for (long n : new long[] {1_000_000L, 100_000_000L, 6_000_000_000L}) {
double expected = (double) n * n / (2.0 * SPACE);
System.out.printf("random 7-char codes for %,d URLs -> ~%,.1f expected collisions%n", n, expected);
}
}
}Real output on JDK 25 (java Base62.java):
62^6 = 56,800,235,584
62^7 = 3,521,614,606,208
0 -> 0 -> 0
61 -> Z -> 61
62 -> 10 -> 62
125 -> 21 -> 125
1,000,000 -> 4c92 -> 1,000,000
3,521,614,606,207 -> ZZZZZZZ -> 3,521,614,606,207
Sequential ids, plain vs scrambled:
id 1,000,000 plain 0004c92 scrambled ZiKOg2Q back 1,000,000
id 1,000,001 plain 0004c93 scrambled BCtC8qL back 1,000,001
id 1,000,002 plain 0004c94 scrambled dWcq0OG back 1,000,002
id 1,000,003 plain 0004c95 scrambled QfVdTcB back 1,000,003
random 7-char codes for 1,000,000 URLs -> ~0.1 expected collisions
random 7-char codes for 100,000,000 URLs -> ~1,419.8 expected collisions
random 7-char codes for 6,000,000,000 URLs -> ~5,111,291.8 expected collisionsTwo problems remain, and both have standard fixes.
The counter must not be a bottleneck or single point of failure. Do not call a central counter on every write. Instead, each write server leases a block of IDs, say one million at a time, from a small allocator table and hands them out from memory:
-- PostgreSQL: one row per sequence; the row lock makes this atomic
UPDATE id_block
SET next_id = next_id + 1000000
WHERE name = 'link'
RETURNING next_id - 1000000 AS block_start;At 40 writes a second, a block of one million lasts almost seven hours even on a single server, so the allocator is called a few times a day per server. If a server crashes, the rest of its block is simply never used, which costs nothing in a 3.5 trillion space. ZooKeeper, etcd or a Redis INCRBY can play the same role.
Sequential codes are guessable. If yesterday's code was 0004c92, someone can try 0004c93 and walk through every link, including private ones. Multiplying the ID by a large constant that has no common factor with 62^7, modulo 62^7, is a reversible shuffle: the output above shows four consecutive IDs turning into codes that look unrelated. Be honest about what it is: obfuscation that stops casual enumeration, not security. Links to private content still need access control or longer random tokens.
Option 3: A key generation service
A separate service generates random 7-character codes ahead of time and stores them in an "unused" table. A write server takes a batch of unused keys, marks them used, and assigns them to new links. Codes look random, and there is no collision check at write time because uniqueness was checked when the key was generated.
The cost is an extra service and an extra datastore to run, and the hard part is handing out each key exactly once across many servers (batches claimed in a transaction, or a queue of keys). If a server dies holding a batch, those keys are lost, which is fine. Choose this when random-looking codes are a requirement.
Option 4: Snowflake-style IDs
Twitter's Snowflake format builds a 64-bit ID from a 41-bit millisecond timestamp, a 10-bit machine ID and a 12-bit sequence, so each machine can mint up to 4,096 IDs per millisecond with no central coordination, and IDs sort by time. It is a great general-purpose ID generator, but for short links it is a poor fit: a 64-bit number needs up to 11 base62 characters, and you must handle clocks moving backwards. Mention it to show you know it, then explain why you did not pick it.
Which short code generation option should you pick?
For the interview, pick option 2: a counter with leased ID blocks, encoded in base62, optionally scrambled. It has no collisions, the shortest codes, a trivial write path and a clear story for scale. Mention option 1 for its deduplication benefit, option 3 if random codes are required, and option 4 as the general distributed ID pattern. If the interviewer asks about deduplication with option 2, add a separate index on a hash of the long URL per owner and return the existing code when it matches.
Redirects: 301 vs 302 and click analytics
Use a 302 Found redirect if you need click analytics or the ability to change, disable or expire links; use 301 Moved Permanently only if links never change and you do not care about counting clicks. The difference is caching: under RFC 9110 a 301 response is heuristically cacheable, so browsers and proxies may remember it and stop asking your server, while a 302 is not cached unless you add explicit cache headers.
307 and 308 are the method-preserving versions of 302 and 301. They matter when the original request is a POST; short links are opened with GET, so 301 or 302 is the normal choice.
Here is the redirect endpoint in Spring Boot 4. It looks up the target, returns a 302 with Cache-Control: no-store so no cache keeps the redirect, and publishes a click event to Kafka without waiting for it:
@RestController
class RedirectController {
private final LinkService links;
private final KafkaTemplate<String, ClickEvent> clicks;
RedirectController(LinkService links, KafkaTemplate<String, ClickEvent> clicks) {
this.links = links;
this.clicks = clicks;
}
@GetMapping("/{code:[0-9a-zA-Z-]+}")
ResponseEntity<Void> redirect(@PathVariable String code,
@RequestHeader(value = HttpHeaders.REFERER, required = false) String referer) {
return links.findTarget(code)
.map(target -> {
// fire and forget: send() returns a CompletableFuture we do not wait on
clicks.send("link-clicks", code, new ClickEvent(code, Instant.now(), referer));
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(target))
.cacheControl(CacheControl.noStore())
.<Void>build();
})
.orElseGet(() -> ResponseEntity.notFound().build());
}
}
record ClickEvent(String code, Instant at, String referer) {}The full redirect flow, including the cache miss path and the asynchronous click event, looks like this:
Keep analytics off the critical path. Kafka buffers bursts from viral links, and analytics workers aggregate counts per link per day into a store built for that, such as an OLAP database or a time-series table. If Kafka is briefly unavailable, the redirect must still succeed; losing a few click events is better than failing the click.
One catch with the controller above: send() is asynchronous only once the producer has topic metadata and buffer space. If the brokers are unreachable or the buffer is full, it blocks for up to the producer's max.block.ms (60 seconds by default) and then throws. For a redirect endpoint, set spring.kafka.producer.properties.max.block.ms to a few milliseconds and wrap the send() call in a try/catch that only logs, so a Kafka outage costs you click events, not redirects.
Caching hot links
Link popularity follows a steep curve: a small share of links gets most of the clicks, and most links are rarely clicked after their first days. That makes a cache very effective. Use cache-aside: read link:{code} from Redis, on a miss read the database and write the entry back with a TTL such as 24 hours (or the time left until the link expires, if that is shorter).
With Spring's cache abstraction the service method can be this small, and the TTL comes from the spring.cache.redis.time-to-live property:
@Service
class LinkService {
private final LinkRepository repository;
LinkService(LinkRepository repository) {
this.repository = repository;
}
@Cacheable(cacheNames = "links", unless = "#result == null")
public Optional<String> findTarget(String code) {
return repository.findById(code)
.filter(link -> link.getExpiresAt() == null || link.getExpiresAt().isAfter(Instant.now()))
.map(Link::getLongUrl);
}
}Spring caches the value inside the Optional, and #result refers to that value, so this unless condition skips caching unknown codes. Points worth adding in the interview:
- Eviction: set Redis to
allkeys-lruorallkeys-lfuso it keeps the hot links when memory fills up. The Redis eviction docs describe each policy. - Negative caching: bots request random codes. If they start hitting the database, cache "not found" for a minute too, with its own short TTL.
- Invalidation: when a link is deleted, edited or expires, delete the cache key after the database change. With one global TTL, a cached link can otherwise keep redirecting until its entry times out.
- Edge caching: for public links that never change, a CDN can serve the redirect too, at the price of fewer analytics.
Scaling deep dive
When the interviewer asks "what if traffic grows 10 times?", work through the components in order of where the load lands.
- Redirect service: stateless, so add instances. At 100,000 redirects a second the cache absorbs most reads.
- Cache: move from one Redis node to Redis Cluster, which shards keys across nodes using hash slots, and add replicas for availability.
- Database reads: add read replicas for cache misses. Replication lag only matters for brand-new links, which can be shared and clicked within a second of creation. Put each new link in the cache at write time, or retry the primary when a replica misses, so a fresh link never returns 404.
- Database size: shard by code. Hashing the code spreads both data and traffic evenly, and every redirect touches exactly one shard. Consistent hashing or a fixed number of logical shards mapped to physical nodes makes adding nodes painless. The "list my links" query then has to go to every shard, which is acceptable for a rare dashboard call, or you keep a small owner-to-codes index elsewhere.
- Multiple regions: serve redirects from every region using local caches and read replicas; keep writes in one region or give each region its own ID blocks so they never clash.
Custom aliases, expiry and deletion
Custom aliases share the same code namespace as generated codes, so the database's unique constraint decides who wins: insert, and if it fails, tell the user the alias is taken. To stop an alias from ever clashing with a future generated code, either require aliases to contain a character that base62 never produces (such as a hyphen) or make them longer than generated codes. Also keep a list of reserved words such as api, admin and login.
Expiry is checked on read: an expired link returns 404 or 410 even if the row still exists. A background job deletes or archives expired rows in batches later. Do not try to delete them at the exact second they expire.
Deletion marks the link disabled, deletes the cache key, and keeps the code reserved, so it is never reissued pointing somewhere else. Reusing codes is a classic security mistake: old links in emails and documents would suddenly lead to someone else's page.
Preventing abuse
Short links are popular with spammers and phishers because they hide the destination. A production design needs:
- Rate limits on link creation per user, API key and IP. See how to design a rate limiter.
- URL checks on creation: valid scheme (
httporhttpsonly, nojavascript:), no links to the shortener itself (redirect loops), and a lookup against a malicious-URL reputation list such as Google Safe Browsing, repeated periodically because good sites can turn bad. - Enumeration resistance: scrambled or random codes, plus rate limits on 404s from one client.
- A way to disable links quickly after a report, which is another reason to prefer 302 over 301.
What interviewers look for in a URL shortener design
- You spot the 100 to 1 read to write ratio early and design the redirect path around it.
- Your estimates end in decisions: 7 characters, a cache of tens of gigabytes, a single primary for writes.
- You compare at least two ID generation strategies and can explain collisions with the birthday problem.
- You know the difference between 301 and 302 and connect it to analytics and link control.
- Analytics are asynchronous and cannot break redirects.
- You raise abuse, reserved words and code reuse without being asked.
Follow-up questions to expect
How do you make sure two servers never generate the same short code?
Give each server its own block of IDs from an atomic allocator (a database row update, Redis INCRBY, or ZooKeeper). Blocks never overlap, so codes never collide, and the allocator is called only when a block runs out.
What happens if the same long URL is shortened twice?
By default it gets two different codes, which is usually fine and lets two users see separate analytics. If deduplication is required, store a hash of the long URL with an index (per owner, if owners exist) and return the existing code when the hash matches.
How would you count unique visitors per link?
Exact unique counts need a set per link, which gets expensive. A HyperLogLog (Redis PFADD and PFCOUNT) estimates distinct counts with a small, fixed amount of memory and a standard error under 1%, which is good enough for a dashboard.
What if the cache cluster goes down?
Redirects fall back to the database, which now takes the full read load. Protect it with read replicas sized for that, connection pool limits, and a circuit breaker so the service degrades with some errors instead of collapsing. Warm the cache again gradually after recovery.
Practice with the full question bank
If you want more problems solved at this level of detail, these TechTriber guides go further:
- System Design Interview: 20 Classic Problems with Solutions: 20 problems solved step by step, with requirements, estimates with the arithmetic shown, API and data model, architecture, a deep dive and failure modes, plus traps and follow-ups.
- Redis and Caching Interview Questions: 50 answers for Java developers on cache-aside, stampede and penetration, Spring Cache, Spring Data Redis, locks, rate limiters, persistence and Redis Cluster.
Related reading
- System design interview framework: the 7 steps, estimation numbers and 15 practice problems.
- System design: designing a rate limiter, which this design relies on for abuse prevention.
- Big-O cheat sheet, for the complexity questions that often come in the same interview loop.
FAQ
How many characters should a short URL code have?
Seven base62 characters give about 3.5 trillion codes, enough for billions of links with room to scramble IDs. Six characters (about 56.8 billion codes) can work for smaller services. Work it out from your estimate of total links over the retention period.
Why base62 and not base64?
Standard base64 includes + and /, which have special meaning in URLs and need escaping. Base62 uses only letters and digits, so codes are always URL-safe and easy to read out. URL-safe base64 variants exist, but the extra two characters add little.
Should a URL shortener use 301 or 302?
Use 302 in most designs, because it keeps every click flowing through your service for analytics and lets you disable or expire links. Use 301 only for permanent links where reducing server load matters more than counting clicks.
Which database is best for a URL shortener?
Any store that does fast lookups by key. PostgreSQL works well at the start and gives a unique constraint for custom aliases; a key-value store such as DynamoDB or Cassandra scales out more easily for very large volumes. Explain the choice from the access pattern, not from brand names.
How does TinyURL generate its short URLs?
TinyURL has not published its internal design, so in an interview do not claim to know it. Describe the standard approaches instead: a counter encoded in base62, a hash of the long URL with collision handling, or pre-generated random keys, and explain which you would choose and why.




