Scope the requirements
The trap is treating this as TinyURL with extra steps. The one structural difference — pastes are kilobytes to megabytes, not a 200-byte URL — drives every interesting decision: where bodies live, how expiry works, and what abuse looks like.
Functional — what it must do
- Create a text paste, get back a short shareable link; anonymous creation is fine.
- Anyone with the link can read the paste.
- Optional expiry per paste (default: never); expired pastes return 404.
- Size limit of 10 MB per paste — stated up front, it shapes the storage design.
- Explicitly cut: editing, accounts, custom URLs, syntax highlighting, visibility ACLs — offer them back as extensions.
Non-functional — the numbers that shape the design
- Read-heavy: 10 reads for every write — milder than a URL shortener but still asymmetric.
- Read latency under 200 ms for a typical paste; large pastes can stream.
- Durable: a paste must never be lost once the create call returns — people paste things they don't keep elsewhere.
- 99.9% availability on reads; creation can degrade briefly.
- Scale target: 10M new pastes per month, multi-year retention.
This is a URL shortener where the payload went from 200 bytes to 10 megabytes. The link-minting part I'll do quickly because it's a solved problem — the design conversation should be about where the bodies live and how expiry works at this size.
Back-of-envelope estimates
The arithmetic is small on requests and large on bytes — which is the whole point. Notice the QPS says one modest database is plenty, while the blob volume says the relational database is the wrong home for bodies.
| Quantity | Estimate | How you got there |
|---|---|---|
| Writes | ~4/s | 10M pastes/month ÷ ~2.6M s per month; peak maybe 10/s. |
| Reads | ~40/s | 10:1 read ratio; peak ~100/s. Trivial QPS — don't shard anything for this. |
| Paste size | ~10 KB avg, 10 MB cap | Most pastes are small code snippets; the cap bounds the abuse case. |
| Blob growth | ~100 GB/month | 10M × 10 KB. ~4 TB over 3 years — pocket change for S3, poison for Postgres rows. |
| Metadata | ~1 KB/paste → ~400 GB / 3 yr | Code, timestamps, expiry, blob pointer. One Postgres primary with replicas, no sharding. |
| Hot cache | a few GB | Cache ~20% of the daily read set (metadata + small bodies); hotness is heavily skewed. |
40 reads a second is nothing — the load numbers justify almost no infrastructure. The number that matters is 100 gigabytes a month of paste bodies, and that's what pushes bodies into object storage with the database holding pointers.
Sketch the API
Three endpoints. The interesting decision is whether reads flow through my service or get handed off to the blob store — I'd keep the service in the path by default and name the escape hatch.
text/plain, streamed. The hot path — one metadata lookup, one blob read.- Serve bodies through the service, not via redirect to S3 — it keeps URLs clean, lets me count views and enforce expiry at read time, and small pastes come straight from cache. For multi-megabyte pastes I'd flip to a short-lived signed S3 URL so the blob store does the heavy lifting; say the threshold out loud (~1 MB) rather than pretending one path fits all.
- The 10 MB limit is enforced at the load balancer via
Content-Lengthbefore the body is read — rejecting oversized uploads after buffering them is paying for the abuse you're trying to prevent.
Data model
One metadata table and an object store. The row holds a pointer, never the body — that separation is the design.
Postgres for metadata — 400 GB over three years and 4 writes a second is comfortably one primary with read replicas, and I get the expiry index and transactions for free. Bodies go to S3 from day one; that's not an optimisation, it's what object stores are for. If writes grew 100× I'd move metadata to DynamoDB keyed on code — and I'd say that migration out loud rather than starting there.
High-level design
Split write and read paths, and split metadata from blobs. The read path is cache-first with the CDN absorbing anything viral; expiry is enforced on read and cleaned up lazily.
- Create: client POSTs the text → gateway rejects anything over 10 MB → write service takes a pre-minted code, writes the body to S3 first, then inserts the metadata row pointing at it, returns the link.
- Read:
GET /{code}→ read service checks Redis for metadata + small bodies (most traffic ends here) → on miss, reads Postgres, checksexpires_at, fetches the body from S3, back-fills the cache. - Expired pastes 404 at read time regardless of whether the janitor has reached them — correctness lives on the read path.
- The janitor trickles through the expiry index in small batches during quiet hours, deleting rows and their S3 objects, and purging cache entries as it goes.
- View counts are rolled up from access logs by a batch job — analytics never touches the read path.
Deep dives — where the interview is won
Store the pointer, not the paste: blob and metadata split
A 10 MB paste in a Postgres row is a disaster in slow motion: it bloats the table and its TOAST storage, wrecks the buffer cache hit rate for every other query, makes replication ship megabytes per row, and turns backups into a blob-archival job. The split is standard: the row stores code → blob_key, size, expiry, and S3 stores the body. Each side scales independently and each is doing the one thing it's good at — the database serves indexed point lookups, the object store serves bytes.
One refinement worth offering unprompted: inline tiny pastes. The median paste is a few KB, and a threshold rule — bodies under ~4 KB live in the row (or at least in the cache), larger ones in S3 — removes an S3 round trip from the majority of reads. The write order matters too: blob first, row second. If the service dies between the two, I've leaked an orphaned S3 object, which the janitor sweeps up — the opposite order can leak a row pointing at nothing, which is a user-visible 500.
Key generation: pre-minted keys beat hashing at request time
Hashing the content (MD5, take 7 base62 chars) sounds neat and fails twice: collisions need a check-and-retry loop on every write, and two users pasting the same text get the same link — so one user's delete or expiry tramples the other's. A key generation service avoids both: an offline process pre-mints random 6–7 char codes into a key table, and write servers grab batches of a few thousand into memory. No hashing, no collision check, no coordination on the request path; 62⁷ ≈ 3.5 trillion codes means the space never runs out.
The KGS is extra infrastructure and a potential single point of failure, so it runs replicated, and a crashed write server strands at most one in-memory batch — which costs nothing against a trillion-code space. The equally good alternative is the URL-shortener move: lease counter ranges from a database row and encode base62. Either answer works; what's being scored is that generation is collision-free by construction and off the hot path.
Expiry is a read-path check, not a delete job
Millions of pastes expiring around the same time must not become a synchronous mass delete. The pattern is lazy expiry plus a slow janitor: the read path compares expires_at on every hit and 404s dead pastes immediately, so correctness never depends on deletion having happened. A background job then walks the expiry index in small batches during quiet hours, deleting rows, their S3 objects, and their cache entries.
The paste-specific wrinkle is that the janitor here does double duty: the same sweep that removes expired rows also garbage-collects orphaned blobs from failed two-phase writes — S3 objects with no surviving row. Mentioning that unifies two loose ends in one mechanism, which is exactly the kind of tidiness interviewers reward.
The viral paste: cache and CDN, because heat is extreme
Steady state is 40 reads a second, but the workload that breaks this design is one paste linked from a huge Reddit thread — 10k requests a second for a single key. Cache-aside in Redis absorbs the metadata and small-body case; for a large hot paste, the read service returns a CDN-backed URL (or the CDN fronts the read path directly), so the edge serves the bytes and my origin sees one request per CDN pop per TTL window.
The complementary abuse is scrapers spraying random codes: those always miss the cache and always hit the database. Negative caching (code → NOT_FOUND, short TTL) closes it cheaply; a Bloom filter over issued codes is the heavier option I'd name but not build at this scale.
Follow-ups you should expect
How is this actually different from a URL shortener?
How would you support private or unlisted pastes?
Someone uploads 10 MB of garbage in a loop — what stops them?
What happens if S3 is slow or down?
Could you support editing pastes?
Where candidates lose the room
- Storing paste bodies in the relational database — the single decision this question exists to test, and it fails candidates who never asked how big a paste is.
- Designing realtime expiry deletion (cron firing deletes at exact expiry times) instead of lazy read-path checks plus batch cleanup — it adds a delete storm to solve a problem the read path already solves.
- Sharding and multi-region replication for a system doing 40 reads a second — skipping the estimate and over-building is scored as harshly as under-building.
- No size limit or abuse controls on an anonymous write endpoint that accepts 10 MB bodies — the interviewer is waiting for you to notice you've built a free file host.
- Writing the metadata row before the blob, creating a window where a real link 500s — write-order reasoning is cheap to say and expensive to miss.