How to Design a URL Shortener Like TinyURL — Step-by-Step for System Design Interviews

System Design

How to Design a URL Shortener Like TinyURL — Step-by-Step for System Design Interviews

A few weeks ago, a friend of mine — three years into his backend career, preparing hard for an SDE role at a company most of us would kill to get into – called me in a slight panic.

“They asked me to How to Design a URL Shortener Like TinyURL,” he said. “I’ve used it a hundred times. I froze.”

That’s the thing about this question. It sounds trivial. Paste a long link, get a short one back. What’s there to design? But that’s exactly why interviewers love it — it’s simple enough to explain in one sentence, and deep enough to fill a 45-minute interview if you know where to dig.

This is the walkthrough I wish someone had given him — not a dry spec sheet, but the story of what’s actually being tested at each step, and where most candidates quietly lose points.

Nailing the Requirements

Every senior candidate makes the same first move: they don’t touch the whiteboard until the requirements are locked in. A design built on the wrong assumptions falls apart no matter how elegant it looks.

Functional requirements — what it must do

  • Shorten it. Submit a long URL, get back a short, unique alias.
  • Redirect it. Visit the short URL, land on the original long URL.

Non-functional requirements — what it must be

  • Highly available. If this goes down, every short link pointing through it breaks at once — a very public failure.
  • Scalable. Traffic spikes hard when a single link goes viral.
  • Low latency on redirects. Nobody notices a URL shortener that works. Everybody notices one that takes three seconds.
Interview tip — always ask whether custom aliases, expiry, and click analytics are in scope. Bolting them on later, unasked, is how "below the line" features quietly become feedback against you.

Back-of-the-Envelope Estimation

This is the part candidates rush — and the part that silently justifies everything that follows. Skip it, and your caching strategy looks like a guess instead of a decision.

  • Assume 100 million new short URLs per month.
  • Over 5 years: 100M × 12 × 5 = 6 billion records.
  • At ~500 bytes/record: 6B × 500B ≈ 3 TB total storage. Very manageable.
  • Read:write ratio ≈ 100:1 — URL shorteners are read-heavy by nature.
  • 200M writes/month → 200M × 100 = 20 billion reads/month.

That last number is the whole ballgame. Twenty billion reads a month is what forces caching, read replicas, and horizontal scaling into the conversation — not because “caching is best practice,” but because the math demands it. Say that out loud; it shows reasoning, not memorization.

API Design

Create a short URL
POST
POST /api/v1/shorten
Content-Type: application/json

{
  "long_url": "https://example.com/very/long/path/to/resource",
  "custom_alias": "my-link",          // optional
  "expires_at":  "2026-12-31T00:00:00Z" // optional
}
201 Created
{
  "short_url":  "https://tinyurl.com/abc123",
  "short_code": "abc123",
  "expires_at": "2026-12-31T00:00:00Z"
}
StatusMeaning
400Bad Request — invalid URL format
409Conflict — custom alias already taken
429Too Many Requests — rate limit exceeded

Redirect to the original URL

GET
GET /abc123
302 Found
Location: https://example.com/very/long/path/to/resource
StatusMeaning
404Not Found — short code doesn’t exist
410Gone — link has expired

Why 302, not 301?

A favorite interviewer follow-up. 301 (Permanent) gets cached by the browser forever — fast, but you lose the ability to track clicks or change the destination. 302 (Temporary) means the browser asks your server every time — slightly more load, but you keep click analytics and the ability to kill or redirect a link at any time. Most production shorteners use 302 for exactly this reason.

Designing the Database Schema

Before drawing boxes and arrows, nail down what’s actually being stored — interviewers often ask you to sketch this explicitly, and it directly feeds the SQL-vs-NoSQL question later.

Core table — urls

ColumnTypeNotes
idBIGINT (PK)Snowflake-generated unique ID — the value that gets Base62-encoded into the short code
short_codeVARCHAR(10)Unique index. Base62 string or custom alias — the lookup key on every redirect
long_urlVARCHAR(2048)The original URL
is_custom_aliasBOOLEANUser-chosen vs. system-generated
user_idBIGINT, nullOnly needed if accounts/ownership are in scope
created_atTIMESTAMP
expires_atTIMESTAMP, nullDrives the 410 check on redirect
The single most important decision here: short_code needs a unique index. Every redirect does an exact-match lookup on it, and the same index is what enforces "custom alias already taken" at the database level — cheaper and safer than a check-then-insert in application code.

Optional — click_events (only if analytics is in scope)

ColumnTypeNotes
idBIGINT (PK)
short_codeVARCHAR(10)Indexed, references urls.short_code
clicked_atTIMESTAMP
referrerVARCHAR(255), null
geoVARCHAR(100), null

Keeping this as a separate table — fed asynchronously via a queue, as covered in the scenario questions below — means the hot redirect path never writes to it synchronously.

urls idBIGINT PK short_codeVARCHAR(10) UQ long_urlVARCHAR(2048) is_custom_aliasBOOLEAN user_idBIGINT, null created_atTIMESTAMP expires_atTIMESTAMP, null 1 : N (async) click_events (optional) idBIGINT PK short_codeindexed FK clicked_at, referrer, geo
FIG. 00 — DATA MODEL

SQL or NoSQL for urls?

Either is defensible in an interview, but the reasoning matters more than the pick. NoSQL (DynamoDB, Cassandra) is favored when the priority is horizontal write scale and built-in replication at billions of rows — short_code becomes the partition key, giving O(1) lookups since the schema has no relationships to speak of. SQL is favored when you want a database-enforced unique constraint on short_code and simple ACID guarantees around alias creation, at the cost of planning sharding yourself as the table grows.

High-Level Architecture

Here’s how a request actually moves through the system – the write path in amber, the read path in teal, kept deliberately separate because writes are rare and reads are relentless.

CLIENT LOAD BALANCER rate limit + validation WRITE SERVICE READ SERVICE ID GENERATOR snowflake id → base62 REDIS CACHE LRU · TTL · cluster HIT PRIMARY DATABASE short_code ↔ long_url miss READ REPLICA replicated copy replication 201 → short_url returned 302 → redirect (or 404 / 410)
FIG. 01 — HIGH-LEVEL ARCHITECTURE write path   read path

The write path: load balancer → write service → ID generator (unique numeric ID → Base62 short code) → primary database → short URL returned.

The read path: load balancer → read service → Redis. Cache hit → redirect immediately, sub-10ms. Cache miss → read replica (never the primary — that’s asking for a bottleneck) → check expiry (410 if expired, queued for async cleanup) or missing (404) → on success, write back to Redis with a TTL, then return 302.

That write-back-on-miss step is what keeps the cache warm without pre-populating it: popular links stay fast, forgotten ones simply age out.

How Do We Actually Generate the Short Code?

This is where an interviewer usually leans forward — there’s no single right answer, only trade-offs.

  • MD5 / hash-based — simple, but collisions are a real risk at billions of records; needs a resolution strategy.
  • Random string — generate, check DB for collision, retry — but that check-then-retry loop adds latency and doesn’t parallelize cleanly.
  • Base62(unique ID) — the common answer. Generate a globally unique, ordered ID (often via Snowflake: timestamp + machine ID + sequence, 64 bits), then encode it with the 62 characters [0-9, a-z, A-Z].
NEW LONG URL write request SNOWFLAKE ID timestamp | machine | seq 64-bit unsigned int BASE62 ENCODE [0-9 a-z A-Z] abc123
FIG. 02 — SHORT CODE GENERATION PIPELINE

Base62-over-Snowflake wins most interviews because it sidesteps collisions entirely — the ID is already unique before it’s ever encoded — and needs no coordination lookup before writing, which is exactly what a distributed, high-write system wants.

Caching — Where the Real Scale Lives

Remember the 20-billion-reads-a-month number? This is where it gets absorbed.

GET /abc123 REDIS CHECK LRU + TTL cluster hit 302 in <10ms miss READ REPLICA query long_url + expiry found → cache + 302 not found → 404 / 410
FIG. 03 — REDIRECT DECISION FLOW
  • Redis as key-value store: short_code → long_url.
  • LRU eviction — rarely-clicked links get pushed out first.
  • TTL per entry — stale or expired links don’t linger.
  • Clustering across nodes for capacity and fault tolerance — no single box holding it all.
A useful mental check: if a redirect resolves in under ~10ms, that's almost certainly a cache hit. Noticeably slower means a cache miss fell through to the database — worth mentioning when discussing observability.

Scenario-Based Interview Questions (Actually Asked at FAANG/MAANG)

Reading the design is one thing. Defending it under follow-ups is where interviews are won or lost. Here’s how to think through the ones that come up most.

Final Thoughts

The URL shortener question isn’t hard because the system is complex — it’s hard because it’s deceptively simple, and simplicity is exactly what tempts candidates into skipping the reasoning interviewers are listening for. Nail the requirements, justify your numbers, and defend every trade-off — hashing vs. Snowflake, SQL vs. NoSQL, 301 vs. 302 — and this stops being a question to fear and becomes one of the easiest wins in your loop.

Next up: a rate limiter or a distributed cache — both share a lot of DNA with what we just built here.

Got a scenario question from a real interview that isn't on this list? Drop it in the comments — the best ones get added.

Leave a Comment