A system design interview asks you to design a whole service on a whiteboard: requirements, rough numbers, an API, a data model, the parts that make it fast and the parts that make it fail. The URL shortener is the most common question because every part of it has a trade-off you can defend. Most answers stay on the whiteboard. This one was built and run: Spring Boot 4.1, PostgreSQL, Redis, 24 passing tests, and real command output for every claim.
| Design question | Choice in this build | Why (and the alternative) |
|---|---|---|
| How are codes made? | Counter id, then base62, scrambled to 7 characters | No collisions; random codes need a collision check |
| Where do ids come from? | Database sequence, claimed in blocks of 100 | One round trip per 100 ids; Snowflake needs no database |
| How are reads fast? | Redis cache-aside, plus negative caching | Redirects are about 100 times more common than creates |
| Redirect status? | 302, not 301 | A 301 is cached by browsers and clicks stop being counted |
| How is abuse limited? | Token bucket in one atomic Lua script | Works across instances; only creation is limited |
| Expiry? | expires_at column, 410 Gone, cache TTL capped | No cleanup job needed for correctness |
Here is the first thing it does, copied from the run: create a link, then follow it.
$ curl -si -X POST /api/v1/urls -d '{"url":"https://ankurm.com/java-interview-preparation-guide/"}'
HTTP/1.1 201
X-RateLimit-Remaining: 4
Location: http://localhost:8080/6MelkkB
{"code":"6MelkkB","shortUrl":"http://localhost:8080/6MelkkB","expiresAt":null}
$ curl -si -X GET /6MelkkB
HTTP/1.1 302
Location: https://ankurm.com/java-interview-preparation-guide/
Versions used here. Spring Boot 4.1.1, Spring Framework 7.0.9, Tomcat 11.0.24, JDK 25.0.4.1 (source level 21), PostgreSQL 16.15, Redis 7.0.15. Tests use H2 and in-memory stand-ins so mvn test needs no services. Code: system-design-url-shortener. Checked 22 October 2026.
Step 1: Requirements and back-of-the-envelope numbers
State the scope, then do the arithmetic out loud. Interviewers want to see that numbers drive decisions (cache or not, one database or many).
Functional and non-functional requirements
- Create a short link for a long http or https URL; optionally a custom alias and an expiry time.
- Redirect from the short code to the long URL; count clicks; report stats.
- Redirects must be very fast and highly available; creates can be slower.
- Codes should be short and not trivially guessable by adding one.
- Protect the create endpoint from abuse. Out of scope: user accounts, analytics dashboards, link editing.
The estimate (stated assumptions, not measurements)
Assume 100 million new links per month and 100 redirects per created link. These are example figures for the exercise, not data from this build.
- Writes: 100,000,000 / (30 × 86,400 s) ≈ 39 per second, call it 40 on average.
- Reads: 100 × that ≈ 4,000 per second on average; plan for several times that at peak.
- Storage over 5 years: 100M × 12 × 5 = 6 billion links; at about 500 bytes a row that is about 3 TB.
- Code length: 626 is about 56.8 billion and 627 about 3.5 trillion, so 6 billion links need at least 6 base62 characters. This build uses 7 for headroom.
Conclusion to say aloud: read-heavy, small rows, one primary database is enough at first, and a cache in front of reads is the big win.
Step 2: The API
| Method and path | What it does | Success | Failures |
|---|---|---|---|
POST /api/v1/urls | Create a link: url, optional alias, optional ttlSeconds | 201 and Location | 400 invalid, 409 alias taken, 429 rate limited |
GET /{code} | Redirect | 302 and Location | 404 unknown, 410 expired |
GET /api/v1/urls/{code}/stats | Target, clicks, dates | 200 | 404 |
Validation is small but real: only http and https (an ftp: or javascript: URL could be abused), a maximum length, and alias rules including reserved words. Real responses, using RFC 9457 problem details:
$ curl -si -X POST /api/v1/urls -d '{"url":"https://example.com/two","alias":"demo-link"}'
HTTP/1.1 409
{"detail":"alias already taken: demo-link","instance":"/api/v1/urls","status":409,"title":"Conflict"}
$ curl -si -X POST /api/v1/urls -d '{"url":"ftp://example.com/file"}'
HTTP/1.1 400
{"detail":"only http and https URLs are allowed","instance":"/api/v1/urls","status":400,"title":"Bad Request"}
$ curl -si -X POST /api/v1/urls -d '{"url":"https://example.com","alias":"api"}'
HTTP/1.1 400
{"detail":"alias must be 4-32 chars of A-Z a-z 0-9 _ -","instance":"/api/v1/urls","status":400,"title":"Bad Request"}
Notice that the alias conflict is detected by the database’s unique constraint (DuplicateKeyException), not by a check-then-insert, which would race. The 302 versus 301 choice matters: a 301 tells browsers to cache the redirect forever, so later clicks would never reach the server and could not be counted.
Step 3: Generating short codes
This is the heart of the interview. Name three approaches and pick one.
| Approach | Pros | Cons |
|---|---|---|
| Hash the long URL, take the first N characters | Same URL gives same code | Collisions; need a check and retry; different users may want different codes |
| Random code | Unguessable | Collision check on every insert; wasted attempts as the table fills |
| Counter id encoded in base62 | No collisions by construction; short | Sequential codes are guessable, so they need scrambling; the counter is a coordination point |
This build uses the counter. First, base62 encoding turns the number into characters:
private static final String ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
public static String encode(long value) {
if (value < 0) throw new IllegalArgumentException("negative id: " + value);
if (value == 0) return "0";
StringBuilder sb = new StringBuilder();
while (value > 0) {
sb.append(ALPHABET.charAt((int) (value % 62)));
value /= 62;
}
return sb.reverse().toString();
}
Allocating ids in blocks
A database sequence is the simplest correct counter, but one round trip per link adds latency. Declaring it with INCREMENT BY 100 lets each application instance claim 100 ids at once and hand them out from memory:
CREATE SEQUENCE IF NOT EXISTS url_id_seq START WITH 1 INCREMENT BY 100;
public synchronized long nextId() {
if (next >= blockEnd) {
Long start = jdbc.sql("SELECT nextval('url_id_seq')").query(Long.class).single();
next = start;
blockEnd = start + BLOCK_SIZE;
}
return next++;
}
Two instances can never overlap because the database hands out disjoint blocks. The cost is that a restart wastes the rest of a block, which does not matter because ids need to be unique, not gap-free. The real output shows both effects:
--- instance A: three links
id | code | len
----+---------+-----
1 | 6MelkkB | 7
2 | 7qqChen | 7
3 | J4Wn4LP | 7
--- instance A stopped and restarted (ids 4..100 of its first block are never used)
id | code | len
-----+---------+-----
1 | 6MelkkB | 7
2 | 7qqChen | 7
3 | J4Wn4LP | 7
101 | IZKi2wO | 7
(4 rows)
--- two instances running at once never overlap: B starts while A is alive
id | code
-----+---------
102 | DDCfjej
201 | Globq13
After the restart the next id is 101, and instance B’s first id is 201 because instance A already claimed block 101 to 200. No overlap, as claimed.
Why the codes do not look sequential
Ids 1, 2, 3 produced 6MelkkB, 7qqChen, J4Wn4LP, not 1, 2, 3. A small CodeScrambler runs each id through a 4-round Feistel network over 40 bits. A Feistel network is a bijection whatever its round function does, so two ids can never produce the same scrambled value: uniqueness is inherited from the counter and no collision check is needed. The result is below 240 (about 1.1 trillion), which is below 627, so it always fits in 7 characters.
public static long scramble(long id) {
if (id < 0 || id >= DOMAIN) throw new IllegalArgumentException("id out of range for the 40-bit scrambler: " + id);
int left = (int) (id >>> HALF_BITS) & HALF_MASK;
int right = (int) id & HALF_MASK;
for (int key : ROUND_KEYS) {
int next = left ^ roundFunction(right, key);
left = right;
right = next;
}
return ((long) left << HALF_BITS) | right;
}
Be honest about what this buys. The scrambler is obfuscation, not encryption. It stops someone from adding one to a code and finding the next link, but it does not make codes secret. If a link must be unguessable, use long random codes and a collision check. A test (noTwoIdsShareACodeAcrossTwoMillionConsecutiveIds) checks the no-collision property over two million ids.
The alternative: Snowflake ids
If you do not want a database in the id path at all, a Snowflake-style generator builds the id from 41 bits of milliseconds since a custom epoch, 10 bits of node id and 12 bits of per-millisecond sequence. It is time-ordered and needs no coordination, but each instance needs a unique node id, the clock must never go backwards (the generator refuses to issue ids if it does), and the ids are large, so the codes are longer. This build includes it behind a profile:
code=7sWzEmdQLw length=10
code=7sWzF1xlNQ length=10
code=7sWzF7djyi length=10
id | code
--------------------+------------
106666141551718400 | 7sWzEmdQLw
106666141778210816 | 7sWzF1xlNQ
106666141862096896 | 7sWzF7djyi
(3 rows)
Ten characters instead of seven is the price. In an interview, give the trade-off in one sentence: block allocation for short codes with one small database dependency, Snowflake for no dependency and longer codes.
Step 4: Make redirects fast with cache-aside
Redirects dominate traffic, so they go through Redis first. The pattern is cache-aside: look in the cache, on a miss read the database and fill the cache.
public String resolve(String code) {
var cached = cache.get(code);
if (cached.isPresent()) {
if (UrlCache.NOT_FOUND.equals(cached.get())) throw new NotFoundException(code); // negative cache hit
cache.incrementClicks(code);
return cached.get();
}
ShortUrl u = repo.findByCode(code).orElse(null);
if (u == null) {
cache.put(code, UrlCache.NOT_FOUND, props.negativeCacheTtl());
throw new NotFoundException(code);
}
Instant now = clock.instant();
if (u.expiresAt() != null && !u.expiresAt().isAfter(now)) throw new ExpiredException(code);
Duration ttl = props.cacheTtl();
if (u.expiresAt() != null) { // never cache past the link's own life
Duration remaining = Duration.between(now, u.expiresAt());
if (remaining.compareTo(ttl) < 0) ttl = remaining;
}
cache.put(code, u.longUrl(), ttl);
cache.incrementClicks(code);
return u.longUrl();
}
Run against real Redis, the first redirect misses and fills the cache with a one-hour TTL; the next nine are hits, and the click counter lives in Redis:
code = 7qqChen
$ redis-cli EXISTS url:7qqChen # before the first redirect
0
--- after 1st redirect (cache miss, database read, cache filled)
$ redis-cli GET url:7qqChen
https://example.com/cache-demo
$ redis-cli TTL url:7qqChen
3600
cache hits +0, misses +1
--- after 9 more redirects
cache hits +9, misses +1
$ redis-cli GET clicks:7qqChen
10
Negative caching
Attackers and broken clients request codes that do not exist. Without care, every one of those goes to the database. Caching the absence for a short time stops that. The first lookup of an unknown code misses and caches a marker; the second is answered from the cache (misses stay at 3):
cache misses: before=2 after-first-404=3 after-second-404=3
$ redis-cli GET url:Zq9Zq9 # the negative-cache marker
-
$ redis-cli TTL url:Zq9Zq9
60
The negative TTL is deliberately short (60 seconds) so a code that gets created a moment later is not hidden for long. The next section shows the related expiry rule.
Expiry without a cleanup job
A link with ttlSeconds=2 redirects, then returns 410 Gone. The cache TTL is capped at the link’s remaining life, so Redis cannot keep serving a dead link:
created ArYvqWu with ttlSeconds=2
t=0s -> 302
$ redis-cli TTL url:ArYvqWu # capped at the link's remaining life, not the 1 h default
2
t=3s -> 410
What can go wrong with this cache design?
- Stampede: when a hot key expires, many requests hit the database at once. Mitigations: jittered TTLs, a short lock or request coalescing, or refresh-ahead for hot keys.
- Stale data: if links could be edited or deleted, you must evict the key (
UrlCache.evictexists for this). This build has no edit, which is why a TTL alone is safe. - Click counts: counters in Redis are fast but not durable. For exact analytics, publish click events to a log and aggregate offline.
- Memory: set a max memory and an eviction policy; the database remains the source of truth.
Spring side of Redis: Redis with Spring Boot 4.1 and the Spring cache abstraction (and why it was not used here: the negative marker and the TTL cap need explicit control).
Step 5: Rate limiting with a token bucket in Lua
Only creating links is limited. Redirects are cheap, cached, and exactly what you want to serve at full speed. The bucket holds up to 5 tokens and refills 0.5 per second. Reading, refilling and spending the tokens must happen as one step, or two instances will both see the last token. A Redis Lua script runs atomically on the server:
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then tokens = capacity; ts = now end
tokens = math.min(capacity, tokens + math.max(0, now - ts) * refill / 1000)
local allowed = 0
if tokens >= 1 then tokens = tokens - 1; allowed = 1 end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / refill * 1000) + 1000)
local retry = 0
if allowed == 0 then retry = math.ceil((1 - tokens) / refill) end
return {allowed, math.floor(tokens), retry}
Using the Redis server’s own clock (TIME) avoids disagreement between application servers. Real output: a burst of five succeeds, the sixth is rejected with Retry-After, and after waiting 2.2 seconds one more request succeeds. Thirty redirects in a row are never limited.
capacity 5, refill 0.5 tokens/second (one every 2 s)
request 1 -> HTTP/1.1 201 X-RateLimit-Remaining: 4
request 2 -> HTTP/1.1 201 X-RateLimit-Remaining: 3
request 3 -> HTTP/1.1 201 X-RateLimit-Remaining: 2
request 4 -> HTTP/1.1 201 X-RateLimit-Remaining: 1
request 5 -> HTTP/1.1 201 X-RateLimit-Remaining: 0
request 6 -> HTTP/1.1 429 X-RateLimit-Remaining: 0 Retry-After: 2
request 7 -> HTTP/1.1 429 X-RateLimit-Remaining: 0 Retry-After: 2
$ redis-cli HGETALL rl:127.0.0.1
tokens
0.046000000000000082
ts
1792656760797
--- sleeping 2.2 s
request 8 -> HTTP/1.1 201 X-RateLimit-Remaining: 0
--- redirects are not limited: 30 GETs in a row
302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302 302
One real-world trap: behind a load balancer, getRemoteAddr() is the balancer’s address, so every client shares one bucket. Configure server.forward-headers-strategy so the filter sees the real client, and only trust forwarded headers from your own proxy. Related: Spring Framework 7 resilience annotations.
Step 6: Scaling and trade-offs the interviewer will push on
When does one PostgreSQL stop being enough?
Reads are already absorbed by the cache. Writes at tens or hundreds per second are trivial for one primary. If you outgrow it: add read replicas for cache misses, then shard by the code (hash of the code), since every lookup has the code. Blocks of ids still work if each shard has its own sequence and you encode the shard in the id.Should the same long URL return the same code?
Not here: each create gets an independent code (testsameLongUrlGetsAnIndependentCodeEachTime), so two users can have separate stats and expiry. Deduplicating needs a unique index on a hash of the URL and breaks per-user settings; say that trade-off.
How do you make it highly available?
Stateless app instances behind a load balancer; Redis with replication and a fallback to the database when it is unavailable (a cache failure should slow redirects down, not stop them); PostgreSQL with a standby. Health checks and graceful shutdown on the platform: Kubernetes probes and graceful shutdown.How do you prevent abuse (phishing, malware links)?
Validate schemes, check against a safe-browsing service asynchronously, rate-limit creation, allow reporting and disabling a code, and keep a deny list of domains. State that a shortener is an attractive target and this is a product risk, not only a technical one.What would you monitor?
Redirect latency (p50, p99), cache hit ratio, database query time on misses, 4xx and 5xx rates, rate-limit rejections, and sequence block consumption. See Micrometer and OpenTelemetry observability. Note that this build exposes a diagnostic/api/v1/debug/cache-stats endpoint that exists only for the demo and must be removed before shipping.
Step 7: The tests
24 tests, no sleeping: time is injected through java.time.Clock. mvn test uses H2, an in-memory cache and an in-memory token bucket, so it needs neither PostgreSQL nor Redis.
Show all 24 test results (real output)
PASS Base62Test.knownValues
PASS Base62Test.roundTripsAcrossTheRange
PASS Base62Test.rejectsGarbage
PASS Base62Test.sevenCharactersCoverAboutThreePointFiveTrillion
PASS CodeScramblerTest.noTwoIdsShareACodeAcrossTwoMillionConsecutiveIds
PASS CodeScramblerTest.everyCodeIsExactlySevenCharacters
PASS CodeScramblerTest.idsBeyondTheFortyBitDomainFallBackToPlainBase62
PASS CodeScramblerTest.consecutiveIdsDoNotGiveConsecutiveCodes
PASS RateLimitTest.burstOfFiveThenRejectedThenRefilled
PASS RateLimitTest.redirectsAreNeverRateLimited
PASS SnowflakeIdGeneratorTest.idsIncreaseWithinOneThread
PASS SnowflakeIdGeneratorTest.base62CodeIsLongerThanForASequenceId
PASS SnowflakeIdGeneratorTest.refusesToIssueIdsWhenTheClockGoesBackwards
PASS SnowflakeIdGeneratorTest.idsAreUniqueAcrossEightThreads
PASS SnowflakeIdGeneratorTest.nodeIdIsEncodedInTheId
PASS SnowflakeIdGeneratorTest.rejectsOutOfRangeNodeId
PASS SnowflakeProfileTest.snowflakeIdsGiveLongerCodes
PASS UrlShortenerApiTest.sameLongUrlGetsAnIndependentCodeEachTime
PASS UrlShortenerApiTest.customAliasWorksAndSecondUseIsAConflict
PASS UrlShortenerApiTest.unknownCodeIs404AndTheMissIsNegativelyCached
PASS UrlShortenerApiTest.createsAShortLinkAndRedirectsWith302
PASS UrlShortenerApiTest.countsClicks
PASS UrlShortenerApiTest.rejectsBadInput
PASS UrlShortenerApiTest.expiredLinkIs410
24 tests, 0 failures
Going deeper. Source files: UrlService.java, CodeScrambler.java, token_bucket.lua. For the object-level counterpart of this question see Low-level design interview: parking lot, and for a larger Spring example Spring Boot microservices project. Microservice theory: Top 40 microservices interview questions.
Common interview questions
1. How do you generate a unique short code?
Encode a unique counter in base62 so there are no collisions, scramble it if sequential codes are a problem, and allocate counters in blocks to avoid a database round trip per link. Name the alternatives (hashing, random with a check, Snowflake) and their costs.2. 301 or 302?
302 if you need every click to reach your server (analytics, expiry, edits); 301 if you want browsers and CDNs to cache the redirect and you accept losing that visibility.3. How do you handle 100 times more reads than writes?
Cache-aside in Redis in front of the database, with negative caching and a TTL, and stateless instances you can scale horizontally.4. Why a Lua script for the rate limiter?
Reading, refilling and spending tokens must be atomic across all instances. A Lua script runs as one command on the Redis server; separate GET and SET calls from the application would race.5. What happens to ids if an instance crashes?
Up to 99 unissued ids from its block are lost. That is fine: ids only need to be unique. Say you would only worry if the id space were small.Common mistakes
- Skipping the numbers. Without read:write ratio and storage estimates, the cache and database choices have no justification.
- Checking whether an alias exists and then inserting. Use the unique constraint and handle the conflict.
- Using 301 and then wondering why clicks are not counted.
- Caching a link past its expiry time.
- Hashing the URL to 7 characters and ignoring collisions.
- Rate limiting reads as well as writes, or keying the limit on the load balancer’s address.
- Claiming obfuscated sequential ids are secure.
Should you build all of this for a real shortener? For an internal tool, a single table, a database sequence and no cache is enough until measurements say otherwise. This build exists so each interview talking point has something real behind it.
No Comments yet!