SysDesignPrep.com
System design interview question

Design Pastebin

Store and serve 10 M text pastes a day behind unguessable links: expiry that is never late, burn after reading, and abuse takedown.

Last updated 2026-10-27. Difficulty: medium. Patterns: object-storage, expiry, caching, privacy, abuse. Reported at Amazon, Microsoft, Google, Meta, Dropbox, Atlassian.

Walk through a strong candidate's answer, turn by turn.

The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.

Or sit it in the style of Amazon · Microsoft · Google · Meta, run as that company’s round is reported to go.

The short answer

How to design Pastebin in a system design interview, in five steps:

  1. Scope it. Create a paste, read it rendered or raw, visibility: public, unlisted, private, expiry, burn after reading, delete and takedown, view counts, abuse controls.
  2. Set the targets. Read latency: p50 < 50 ms · p99 < 300 ms; Availability: 99.95 % reads · 99.9 % writes; Durability until expiry: no paste lost before it expires; Expiry is exact: 0 reads after expires_at.
  3. Do the numbers. Writes per second: ~120 avg · ~350 peak; Reads per second: ~1.7 k avg · ~5 k peak; Bytes per paste, stored: 10 KB raw → ~3 KB.
  4. Draw the design. Raw content host, CDN edge, View counter, Object storage, Paste service, Rate limiter, Expiry sweeper, Paste store, Change stream.
  5. Go deep where it is hard.
    • Inline below 8 KB, object storage above → Inline when the compressed text is 8 KB or less, object storage above.
    • Dedupe that leaks nothing → Dedupe within a scope: all public pastes together, each signed-in owner’s pastes together.
    • Unlisted means unguessable → 16 base62 characters from a CSPRNG (~95 bits).
    • Expiry, done twice → Check on every read, sweep an hourly expiry index, lifecycle rules as backstop.
    • Burn after reading, exactly once → One conditional delete (or state flip) that returns the content, behind a POST.
    • Caching text that can still disappear → CDN for public and unlisted, CDN-only long lifetimes capped at remaining life, tag purges for deletes and takedowns, no-store for private and burn.

Functional requirements

  • Create a paste. Plain text up to 10 MB (typically ~10 KB), with an optional title and language, anonymous or signed in. Returns a page link, a raw link and, for anonymous pastes, a delete token shown once.
  • Read it rendered or raw. A page with syntax highlighting and line numbers, a raw text view for curl and scripts, and a download. The text is never interpreted as HTML anywhere.
  • Visibility: public, unlisted, private. Public pastes appear in the public feed and in search engines once scanned; unlisted ones are readable by anyone with the link and listed nowhere; private ones only by the signed-in owner, and they are a 404 for everyone else.
  • Expiry. 10 minutes, 1 hour, 1 day, 1 week, 1 month, 6 months, 1 year or never. An expired paste is never served again, from any cache, and its bytes are reclaimed.
  • Burn after reading. Shown to exactly one reader, then gone. A chat app’s link preview or an email scanner must not consume it, and two people opening it at once must not both see it.
  • Delete and takedown. Owners delete with their session or the delete token; trust and safety takes down abusive pastes. Both take effect at every CDN edge within minutes, and a taken-down paste cannot simply be pasted again.
  • View counts. Shown on the page, approximate and a few minutes behind, even though most views are served by the CDN and never reach us.
  • Abuse controls. Rate limits per IP and per account, asynchronous scanning for leaked secrets, malware and spam, a report button, and takedown backed by a hash blocklist.
  • Out of scope. Editing (pastes are immutable; an edit creates a new paste), comments and forks, full-text search across all pastes, end-to-end encrypted pastes (discussed under dedupe), and the moderation console itself.

Non-functional requirements

  • Read latency (p50 < 50 ms · p99 < 300 ms). Edge hits answer in tens of milliseconds anywhere. The p99 comes from origin misses on blob-backed pastes, which add an object storage read; storing small pastes inline keeps most misses to one key-value lookup.
  • Availability (99.95 % reads · 99.9 % writes). Paste links sit in bug reports, chat threads and CI logs, and a read outage breaks all of them at once. A failed create costs one person a retry.
  • Durability until expiry (no paste lost before it expires). Rows replicated three ways across zones, blobs in object storage designed for eleven nines. Deleting late is a cost; deleting early is data loss.
  • Expiry is exact (0 reads after expires_at). People put secrets in 10-minute pastes. Correctness comes from a check on every read and cache lifetimes capped at the remaining life; reclaiming the storage may lag by hours without anyone being able to tell.
  • Unlisted and private stay private (P(guess) < 10⁻⁶ · never in a shared cache). This is the requirement that drives the hardest trade-off. The CDN wants to hold everything for a month, while unlisted links must be unguessable, private and burn content must never sit in a shared cache, and deletes and takedowns must reach every edge. Most of the caching rules exist to reconcile the two.
  • Burn after reading (at most one reader). One successful reveal under concurrent requests, link previewers and retries. Losing the content when the winner’s connection drops is acceptable; showing it twice is not.
  • Abuse response (scan < 1 min · takedown < 5 min globally). Paste sites are used to leak credentials and stage malware. Public pastes reach the public feed only after the scan, and a takedown must clear every CDN edge, not just the database.
  • Scale (10 M writes/day · 150 M reads/day · 5 years). Average 10 KB, 10 MB maximum. Half of all pastes never expire and are kept for at least five years; 65 % of those are unlisted.

Back-of-envelope estimates

  • Writes per second: ~120 avg · ~350 peak. 10 M pastes/day ÷ 86 400 ≈ 116/s, and about 3× that at the daily peak: ~350/s. At ~10 KB each that is ~1.2 MB/s of incoming text. Writes are not the hard part.
  • Reads per second: ~1.7 k avg · ~5 k peak. 15 reads per paste: 150 M reads/day ÷ 86 400 ≈ 1.7 k/s, ~5 k/s at peak. The head matters more than the average: one paste linked from a front page can draw 20 k/s on its own.
  • Bytes per paste, stored: 10 KB raw → ~3 KB. Median ~2 KB, mean 10 KB: 90 % of pastes average 2.5 KB and the other 10 % (logs, dumps) average ~78 KB. zstd shrinks short snippets ~2× and logs ~4×: 0.9 × 1.25 KB + 0.1 × 19.4 KB ≈ 3.1 KB, about 3.3× overall.
  • Storage after 5 years: ~28 TB compressed. 10 M/day × 10 KB = 100 GB/day, ~180 TB raw over five years. Half of all pastes expire within a year, so ~9.1 B never-expiring pastes remain: 9.1 B × 3.1 KB ≈ 28 TB, of which ~10 TB sits inline in the store and ~18 TB in object storage (~14 TB after dedupe). Storage is not the hard part.
  • Blob writes per day: ~1 M (vs 10 M). Keeping content of 8 KB or less (compressed) inline keeps ~90 % of pastes out of object storage: 10 M × 10 % = 1 M PUTs/day instead of 10 M. At ~$5 per million PUTs that is $5 a day rather than $50, and nine in ten origin reads become a single key-value lookup.
  • Id length for unlisted pastes: 16 base62 chars (~95 bits). 65 % of 9.1 B retained pastes are unlisted: N ≈ 6 B. An attacker with G = 10^12 guesses (30 k/s for a year from a botnet) and a target P(any hit) ≤ 10^-6 needs a space ≥ G × N ÷ P = 6 × 10^27. 62^15 ≈ 7.7 × 10^26 is too small; 62^16 ≈ 4.8 × 10^28 gives P ≈ 1.3 × 10^-7. A shortener’s 7 characters would give one hit per ~590 guesses.
  • Origin reads after the CDN: ~380/s avg. 92 % of reads are public or unlisted, so cacheable. With an 85 % edge hit rate on those, origin sees 0.92 × 0.15 + 0.08 (private and burn, never cached) = 22 % of 1.7 k/s ≈ 380/s, ~1.1 k/s at peak. One key-value read each, so a modest store handles it.
  • Expirations to sweep: ~5 M rows/day · ~0.5 M blobs. At steady state everything given an expiry expires: 10 M × 50 % = 5 M rows/day, ~60/s. One in ten has a blob: 0.5 M/day, or ~500 batched delete calls of 1,000 keys. Small, as long as the sweeper reads an index ordered by expiry time instead of scanning 9 B rows.

Components

  • Browser / CLI (web · curl · editor plugins): Creates pastes from the web form, curl or an editor plugin, and reads them as a page or as raw text. Highlights code itself in a Web Worker from the stored language tag, so the server never turns user text into HTML. Reveals a burn paste only on an explicit click, which sends a POST.
  • Raw content host (cookieless domain · text/plain): Origin for pasteusercontent.example, a separate registrable domain that never receives a cookie. Resolves the paste through the paste service, so expiry, visibility and takedown are checked in one place, then returns text/plain with nosniff and a sandboxing CSP, so nothing in a paste can run. A private raw view needs a short-lived signed link issued by the app.
  • CDN edge (two hosts · surrogate keys): Serves paste.example (pages and API) and pasteusercontent.example (raw text). Caches public and unlisted responses for 30 days or the paste’s remaining life, whichever is shorter, keyed by id and tagged p:{id} for purges, and coalesces concurrent misses. Passes POSTs and the private and burn paths straight to origin, and streams its access logs to the view counter.
  • View counter (CDN logs · 1-min batches): Counts views from the CDN’s log stream, because ~85 % of views of public and unlisted pastes never reach the origin. Sums per paste per minute and applies one increment per paste per minute, so a viral paste costs one write a minute instead of 20 k a second. Approximate by design: known bots are filtered out and the number lags by minutes.
  • Object storage (S3-class · lifecycle rules): Content above 8 KB compressed: ~10 % of pastes but ~63 % of stored bytes. Never-expiring pastes live under cas/{scope}/{sha256}, shared within a scope and reference counted; expiring ones under exp/{class}/{id}, unshared, with lifecycle rules that delete each class a day after its maximum life. Private to the paste service, so a blob key is never a URL.
  • Paste service (stateless · zstd · checks): Owns create, read, reveal, delete and report. On write it rate limits, validates UTF-8 and size, hashes, checks the blocklist, compresses, chooses inline or blob, and commits the row last. On read it is the one place the expiry check, visibility rules and takedown state are enforced, and it sets cache lifetimes capped at the paste’s remaining life.
  • Rate limiter (Redis token buckets): Token buckets per IP (grouped by /24 for IPv4 and /64 for IPv6, since one connection usually owns at least a whole /64), per account and per tier, weighted by size so a 10 MB paste costs more than a 1 KB one. Also counts 404s on well-formed ids per network, which is how an id scan shows up. Fails open with conservative local limits if Redis is down.
  • Expiry sweeper (expiry index · batch deletes): Reads closed hours of the expiry index across 64 shards, deletes expired rows with conditional deletes and their blobs in batches of 1,000 keys. Also consumes delete, burn and takedown events: purges the CDN by surrogate key, decrements reference counts on shared blobs, and deletes blobs whose count has been zero for 24 hours. Every operation is idempotent, so it can rerun or overlap the lifecycle rules safely.
  • Paste store (DynamoDB / Cassandra · by id): pastes keyed by id: owner, visibility, expires_at, burn flag, state, language, SHA-256, sizes, and either the zstd content itself (8 KB or less) or a blob key. Beside it: an owner index, the expiry index by (hour, shard), blob reference counts, the hash blocklist, reports and view counters. Point lookups and conditional writes are all it must do well; ~13 TB after five years, ~40 TB with three replicas.
  • Change stream (CDC → Kafka · key = id): Every committed change to a paste row (created, reported, deleted, burned, taken down), published from the store’s change log, so an event exists if and only if the write committed. Trust and safety and the sweeper consume it independently. Keyed by paste id so one paste’s events stay in order.
  • Trust & safety (scanners · reports · takedown): Scans every new paste within a minute: secret detectors (cloud keys, tokens, private key blocks), credential-dump and spam heuristics, and decoded payloads checked against malware feeds. Public pastes enter the public feed only after the scan passes. Reports and scanner hits are queued by severity times current view rate; a takedown flips the row to taken_down and adds its hash to the blocklist.
  • Threat intel (URL and malware reputation): Third-party reputation services: Safe Browsing-style lists for phishing links inside pastes and malware hash feeds for payloads decoded from base64 or hex. Called asynchronously with cached verdicts, never on the write path, because their verdicts arrive after the fact and paste sites are a favourite staging area for malware.

User flows

  1. Create a paste. The write path. Easy at 120 a second; the design is in what happens before the row is written: limits, validation, the blocklist, compression, the inline-or-blob decision and an id nobody can guess.
    1. Client posts the text with its options. The CDN passes POSTs through uncached. Unlike Dropbox there is no presigned upload straight to storage: 10 MB fits in one request, and the service has to validate, hash and compress the bytes anyway.
    2. Paste service checks the rate limits for this IP and this account. Anonymous creators get about 10 pastes a minute and 100 a day per network, counted per /24 for IPv4 and per /64 for IPv6, because one home connection is usually given at least a whole /64. Accounts get limits by tier. The cost is weighted by size, so ten 10 MB dumps cost far more than ten one-line snippets.
    3. Service validates the text, hashes it and checks the hash against the takedown blocklist. Invalid UTF-8 is rejected: this is a text service, and refusing binary blocks raw executables outright. The SHA-256 of the raw bytes is checked against the hashes of every paste ever taken down, so an exact re-paste is refused before anything is stored. Near-duplicates are the scanner’s job, later.
    4. Service compresses with zstd; 8 KB or less stays inline, anything larger is written to object storage first. zstd at a fast level costs microseconds for a typical paste and shrinks text 2 to 4×. The threshold applies to the compressed size, so ~90 % of pastes never touch object storage. The blob is written before the row (commit last): a crash leaves an unreachable orphan for the lifecycle rules, never a row pointing at nothing.
    5. Service draws a random id and writes the row, the owner index and the expiry index in one transaction. The id is 16 base62 characters from a CSPRNG, about 95 bits, so the write needs no coordination: put-if-absent catches the roughly one-in-a-billion collision. The expiry index entry goes in the same transaction, so no paste can exist without a way to expire.
    6. Service returns the links; an anonymous creator gets a delete token, once. Only a hash of the delete token is stored, so a leak of the store does not let anyone delete pastes. The raw link is on a different registrable domain from the page link.
    7. The change stream publishes the new paste and trust and safety scans it in the background. Scanning is asynchronous so a slow detector never delays a create. The link works immediately, but a public paste joins the public feed only after the scan passes, about a minute later, because scrapers watch the feed for leaked credentials.
  2. Read a paste, rendered and raw. Most reads end at the CDN. The origin path is one key-value lookup for nine pastes in ten, and it is the only place expiry, visibility and takedown are checked.
    1. Browser requests the paste page and the CDN serves it from cache. About 85 % of reads of public and unlisted pastes end here. The cached HTML is identical for every viewer: the signed-in header and the owner’s delete button load from a separate, uncached API call, and the CDN ignores cookies in the cache key.
    2. On a miss, the CDN asks the paste service, which reads the row by id. Concurrent misses for the same id are coalesced at the edge, so a burst becomes one origin request per PoP. For 90 % of pastes the content is in the row, so this is the only storage read.
    3. Service applies the read checks: live state, not expired, and visible to this caller. expires_at is compared with the clock on every read, so an expired row that the sweeper has not reached yet still returns 410. A taken-down paste returns 451. A private paste returns 404, not 403, to anyone but its owner, so a guess cannot even confirm that it exists.
    4. For a paste above 8 KB, the service streams the content from object storage. Streamed, not buffered, so a 10 MB paste does not sit in service memory. This hop adds tens of milliseconds to first byte and is where the p99 comes from, which is why it is reserved for the 10 % of pastes large enough to justify it.
    5. Service returns the page with cache lifetimes capped at the paste’s remaining life. The CDN may keep it for up to 30 days, browsers and other proxies for 5 minutes, and neither past expires_at, so expiry never needs a purge. The surrogate key lets one purge clear the page, raw and download variants together. The text is embedded escaped, and the page’s CSP forbids inline script.
    6. A script fetches the raw link; the raw host resolves it through the paste service and returns plain text. The raw host lives on a separate registrable domain, not a subdomain, so no cookie for paste.example is ever sent to it and it is not same-site with the app. If a browser ever rendered a raw paste as HTML, the script would run with no session to steal. nosniff also stops another site from loading a raw paste as a script or stylesheet.
    7. The CDN’s access logs feed the view counter, which adds each paste’s views to its counter once a minute. The origin cannot count views it never sees. One increment per paste per minute caps the write rate of a viral paste at one a minute. The number on the page comes from a separate call cached for 60 seconds, so the immutable page never carries a stale count.
  3. A leaked dump goes viral, then is taken down. The scale-breaking case and the abuse case at once: 20 k requests a second for one paste, which then has to vanish from every edge. The CDN absorbs the load; the hard part is making a takedown stick.
    1. A public paste full of email and password pairs is linked from a large forum and draws 20 k requests a second. Spread across a few hundred PoPs, that is under 100 requests a second at each, trivial for the edge. The paste was created anonymously ten minutes ago with expiry set to never.
    2. Each PoP misses once; coalescing and the CDN’s upper tier turn that into a handful of origin reads. With a shield tier per region, origin sees about one fetch per region, then nothing for up to 30 days. Without coalescing, the first second would send every concurrent miss to origin at once.
    3. Readers report it; reports are written to the store and reach trust and safety on the change stream. The scanner had already flagged it as a likely credential dump (thousands of lines shaped like email:password) and kept it out of the public feed, but the link spread anyway. The queue orders reports by severity times current view rate, so a paste read 20 k times a second jumps to the front.
    4. Trust and safety checks reputations and takes the paste down: the row flips to taken_down and its hash joins the blocklist. For a clear credential dump with scanner and report signals agreeing, an automatic rule acts and a moderator reviews afterwards; ambiguous cases wait for a person. The state flip is conditional, so a takedown never resurrects a paste the owner already deleted.
    5. The takedown reaches the sweeper on the change stream, which purges every cached variant by surrogate key. Page, raw and download responses all carry p:{id}, so one purge clears them everywhere in seconds. Copies in browsers and other proxies live at most 5 minutes, which is exactly why their lifetime is short and only our CDN gets the long one.
    6. The next requests miss and get a 451, cached briefly at the edge; an exact re-paste is refused by the blocklist. Caching the 451 for 60 seconds matters: right after the purge, 20 k requests a second all miss at once, and without a cacheable answer they would all reach origin. Re-pastes with a line changed get past the exact hash and are caught by the scanner’s similarity check instead.
    7. The content is kept under a legal hold for the period policy requires, then the sweeper deletes it. A taken-down paste is unreadable at once but not necessarily erased at once: some takedowns must be preserved for a fixed time for law enforcement. The hold is an attribute on the row, and the sweeper deletes the bytes when it lapses.
  4. Burn after reading: two readers and a bot. The correctness case. A link preview bot, two people clicking at once and a CDN that would happily cache or share one response, against a promise of at most one reader.
    1. The link to a burn paste is shared in a chat app, whose preview bot fetches it. A GET on a burn paste never returns its content, only an interstitial saying it will be deleted after viewing. Preview bots, email security scanners and browser prefetchers only ever see that page, so they consume nothing.
    2. Two people click Show at the same moment; both POSTs pass through the CDN to the paste service. POSTs are never cached or coalesced by the CDN, and the reveal path is excluded from caching by a CDN rule as well, so a wrong header cannot turn it into a cached object.
    3. Each request runs one conditional delete that returns the deleted row; only the first matches. The store applies writes to one key one after another, so exactly one request finds state = active. In SQL this is DELETE … RETURNING content; in Cassandra a lightweight transaction flips the state and only the request told it was applied returns the content it read.
    4. For a burn paste above 8 KB, the winner flips the state to burned and streams the blob. The same conditional write decides the winner; the loser finds state = burned. The blob is deleted later by the sweeper, after a 10-minute delay so a slow download can finish. If the winner’s connection dies mid-stream the content is lost: at most once, never twice.
    5. The winner gets the text with no-store; the other reader gets 410 “already read”. A 410 tells the intended recipient something useful: someone opened it first, so the secret in it should be rotated. The link holder already knows the paste existed, so the 410 leaks nothing new.
    6. The change stream carries the burn to the sweeper, which removes the leftovers. The expiry index entry and the blob are deleted by the same idempotent cleanup that handles expiry and deletes. Burn pastes are never deduplicated, so the blob has no other reader to protect.
  5. Expiry and deletion in the background. Correctness comes from the read path; this flow reclaims the storage. Five million rows a day, swept from an index ordered by expiry time, with lifecycle rules as the backstop.
    1. A one-hour paste is requested at minute 61, before the sweeper has reached it. Any cached copy already lapsed at minute 60, because its lifetime was capped at the remaining life. The origin finds the row, sees expires_at in the past and answers 410 as if the row were gone.
    2. Every minute, the sweeper reads the expiry index buckets for hours that have closed. Each hour is split into 64 shards by hash of the id, so no single partition takes all of an hour’s writes, and 64 workers can drain an hour in parallel by leasing shards. The sweeper only ever touches what is due, never the 9 B rows that are not.
    3. Sweeper deletes the expired rows with a condition on expires_at. The condition makes the delete idempotent and safe against a race with an owner delete or a burn: a row that is already gone simply matches nothing. Owner and expiry index entries go in the same batch.
    4. Sweeper deletes the expired blobs in batches of up to 1,000 keys. Half a million blobs a day is about 500 batch calls. Expiring blobs are never shared, so no reference count is consulted. Lifecycle rules on exp/1d/, exp/7d/, exp/31d/ and exp/366d/ delete anything older than the class plus a day, so even a sweeper that is down for a week cannot leave bytes behind for long.
    5. An owner deletes a never-expiring public paste; the row is marked deleted and the change reaches the sweeper. The read path returns 404 as soon as the row is marked, so the delete is effective at origin immediately; everything after that is cleanup of copies.
    6. Sweeper purges the CDN by surrogate key and decrements the shared blob’s reference count. Deletes are unscheduled, unlike expiry, so they are the case that needs a purge. The blob may be shared with other public pastes of the same bytes, so the sweeper only decrements its count.
    7. A garbage collection pass deletes shared blobs whose count has been zero for 24 hours. The grace period covers a create that is about to increment the count again, and the pass re-reads the count before deleting. A weekly reconciliation recounts references from the paste rows, because a count that is too low is the one bug here that loses data.

Deep dives

Inline below 8 KB, object storage above

Why not put every paste in object storage, the way Dropbox stores files? Or every paste in the database?

The size distribution is very skewed. The median paste is ~2 KB and 90 % are under 16 KB, but the 10 % that are logs and dumps average ~78 KB and carry ~78 % of the bytes. Everything in object storage means 10 M objects a day and a second hop on every origin read of a 2 KB snippet: a PUT costs about $5 per million at list price, about what it costs to store 2 KB for nine years, and object stores answer in tens of milliseconds where a key-value read takes a few. Everything in the database means 10 MB values in a store built for small rows, and paying database prices (roughly ten times object storage per GB) for logs nobody reads twice.

The cost of a paste has two parts, per object (request charges, latency, a second write that can fail on its own, minimum billable sizes in cold tiers) and per byte, and they dominate at opposite ends of the distribution. So split by size, and compress before deciding, because text shrinks 2 to 4× with zstd and the line should be drawn on the bytes actually stored.

  • Inline when the compressed text is 8 KB or less, object storage above chosen
  • Everything in object storage, metadata in the database situational: pastes are mostly large (build logs, artifacts), or the database’s price per byte is far above object storage
  • Everything inline in the database rejected
  • Haystack-style packed volumes we run ourselves situational: at hundreds of billions of small objects, where per-object overhead in a managed store becomes the bill, as it did for Facebook’s photos

The answer: Compress every paste with zstd at write time (about 3.3× on average: short snippets ~2×, logs ~4×), then branch on the compressed size. At 8 KB or less, about 90 % of pastes and ~37 % of stored bytes, the content lives in the paste row, so a read is one lookup and a burn or delete is one conditional write. Above 8 KB the bytes go to object storage first and the row stores the key, committed last, so a crash leaves an orphan for a lifecycle rule rather than a row pointing at nothing. After five years that is ~13 TB in the store (10 TB of content, 3 TB of metadata) against ~14 TB in object storage. On list prices it is roughly a wash by year five, about $1.4 k a month of PUTs saved against about $2 k a month of extra database storage, so I make the choice for the single-hop read and the single-row atomic operations rather than for money. The threshold is configuration; if prices move, the line moves, and a background job can migrate rows across it.

Why 8 KB and not 64 KB?

Below 8 KB compressed is already about 90 % of pastes, so most reads are single-hop. Raising the line to 64 KB moves a large share of the log tail into the database for a small gain in single-hop reads, and pays database prices for content that is read a handful of times. Bigger rows also cost more per read (DynamoDB bills reads in 4 KB units) and slow compaction. In practice I would plot paste count and stored bytes against size from real data and put the line at the knee, where count stops growing much while bytes keep climbing.

Walk me through the five-year storage number.

10 M pastes a day at 10 KB is 100 GB a day, about 180 TB raw over five years. Half of all pastes expire within a year, so roughly 9.1 B never-expiring pastes remain, about 91 TB raw. zstd at ~3.3× brings that to ~28 TB: ~10 TB inline in the store plus ~3 TB of metadata, and ~18 TB in object storage, ~14 TB after dedupe. Replicated three ways the store is ~40 TB on disk. The conclusion is that storage is not where this design is hard; the read path, expiry and privacy are.

Why compress at write time rather than letting the store or the CDN do it?

Stored bytes are what we pay for over five years, and compressing once costs microseconds per paste at a fast zstd level. It also decides the inline threshold, which must be measured on stored bytes. On the way out, clients that accept zstd get the stored bytes as they are; others get gzip or Brotli from the CDN after the origin decompresses. A trained zstd dictionary per language would compress short snippets noticeably better, which I would try once the basics are in place.

The blob PUT succeeds and the row write fails. What happens?

The blob is an orphan that nothing can reach, because no row points at it and blob keys are never URLs. Expiring blobs fall to the lifecycle rule on their prefix within days; content-addressed ones have a blobs row with no references, which the garbage collector deletes after its grace period. A weekly diff of an object storage inventory against the store catches anything else. The reverse order would be the real bug: a row pointing at bytes that never arrived is a paste that returns errors forever.

Would you ever move content between inline and blob after it is written?

Pastes are immutable, so nothing grows past the line on its own; only a threshold change would justify it. Then a background job walks rows by id range, writes the blob first, and updates the row with a condition on its previous version, at a low rate. It is cheap to have and rarely worth running for small gains.

Dedupe that leaks nothing

Thousands of people paste the same log or the same leaked dump. Why not store each unique content once, globally?

Exact duplicates are common on a paste site: bots repost identical spam, scripts post identical output, and a leaked dump is re-pasted again and again. Content addressing, with the SHA-256 of the bytes as the key, makes the second copy free, and the hash is needed anyway for the takedown blocklist. On our numbers it saves about 20 % of blob bytes, ~4 TB over five years.

The danger is that dedupe is an oracle. If identical content returns the existing paste’s id (“same content, same link”), anyone who can produce the bytes of someone else’s unlisted paste gets its link. Even storage-only dedupe can leak through timing, since a write that skips the upload returns faster. Secrets are exactly the content an attacker can partly guess: a config template with one unknown password, tried a few thousand times. The rule is that one user’s write must never reveal whether another user’s non-public bytes exist.

  • Global dedupe that returns the existing paste rejected
  • Global dedupe of blobs, with separate ids rejected
  • Dedupe within a scope: all public pastes together, each signed-in owner’s pastes together chosen
  • No dedupe; keep the hash only for the blocklist situational: the duplicate share turns out small, or the team would rather not own reference-counted garbage collection

The answer: Content-address only blobs, the 10 % above 8 KB, and only within a scope: the key is cas/{scope}/{sha256}, where the scope is pub for public pastes and the owner id for a signed-in owner’s unlisted and private ones. Anonymous unlisted pastes are never deduplicated, and inline rows are not deduplicated at all, since they are small and die with their row. A blobs row holds the reference count, incremented in the same transaction as the paste row and decremented by the sweeper on delete; a blob is deleted only after its count has been zero for 24 hours and a re-read agrees. Expiring pastes stay out of content-addressed storage: their blobs live under exp/{class}/{id}, unshared, so lifecycle rules can delete them by age without consulting a count. The client always uploads the full text and gets the same response either way, so the response never reveals a match, and any timing difference can only tell an owner about their own pastes or about public ones. Every paste’s SHA-256, inline or not, is also checked against the takedown blocklist.

Make the attack on global dedupe concrete.

A company’s deploy job pastes its .env file as unlisted every night, and an attacker knows the template but not the database password. With link-returning dedupe they paste the template with candidate passwords, and the one that matches comes back with the victim’s URL. With blob-level dedupe the signal is a faster response, which is noisy but measurable over many tries. Scoping removes the signal, because the attacker’s writes can only collide with their own pastes or with public ones.

Why not deduplicate expiring pastes too?

Lifecycle rules delete by object age, and a shared blob’s age has nothing to do with the newest paste pointing at it: a one-week blob created on Monday and reused on Sunday would be deleted while Sunday’s paste still needs it. Keeping expiring blobs unshared lets the provider delete them by prefix and age with no reference count to get wrong. The duplicates that matter most, reposted dumps and spam, are mostly posted to never expire anyway.

Reference counts drift. How would you know, and what protects you?

A weekly job recounts references from the paste rows, through an index from blob key to paste ids, and compares them with the stored counts. A count that is too high only wastes space; one that is too low loses data, so the garbage collector never trusts the count alone and checks that index for live references before it deletes. Any blob whose recount exceeds its stored count raises an alert, because that is a bug in the write or delete path.

Does the hash help with abuse?

Yes. The hash of every taken-down paste goes into a blocklist checked on each write, so an exact re-paste gets a 451 before anything is stored. A dump with one line changed defeats an exact hash, so the scanner also compares line-level MinHash or SimHash signatures in the background. Bots that add random padding defeat both, so the blocklist is one layer among several, not the defence.

What about client-side encryption, like PrivateBin?

PrivateBin encrypts in the browser and puts the key in the URL fragment, which browsers never send to the server, so the server stores ciphertext it cannot read. That rules out dedupe, scanning and server-side rendering, which is the point: the operator cannot see the content. I would offer it as an opt-in encrypted mode with those features visibly off, not as the default, because the default has to stay scannable for abuse.

Unlisted means unguessable

The URL shortener used 7-character codes. Why are those not good enough for unlisted pastes?

An unlisted paste is protected by nothing but its URL: anyone who has it can read the paste, and anyone who can guess it can too. A shortener’s codes only need to be unique and not obviously sequential, because its links exist to be clicked. Unlisted pastes hold logs, configs and keys that people believe are private. With ~6 B live unlisted pastes, 7 base62 characters (62⁷ ≈ 3.5 × 10¹²) means one valid id in every ~590 guesses, and 8 characters one in every ~36,000: a botnet guessing 1,000 times a second finds a paste every 36 seconds.

Rate limits per IP do not fix this, because guessing spreads across a botnet and every address stays under any limit. The defence has to be the size of the space: choose a length at which an attacker with an absurd budget still finds nothing, and draw ids from a cryptographic random source so there is no structure to exploit.

  • Shortener-style codes: a ranged counter with a scramble, 7 or 8 characters rejected
  • 16 base62 characters from a CSPRNG (~95 bits) chosen
  • Short public id plus a separate secret token for unlisted pastes situational: public links must stay short while unlisted ones carry a capability that can be rotated
  • Decryption key in the URL fragment, encrypted in the browser situational: users need the server itself to be unable to read the paste

The answer: Every paste gets 16 base62 characters from a CSPRNG, about 95 bits. Sizing: with N ≈ 6 × 10⁹ live unlisted pastes and an attacker budget of G = 10¹² guesses (30 k a second for a year), the chance of any hit is G × N ÷ 62¹⁶ ≈ 1.3 × 10⁻⁷; 15 characters would give ~8 × 10⁻⁶, above the one-in-a-million target, so 16 is the shortest that meets it. Generation needs no coordination: put-if-absent catches the rare collision. Around the id: private pastes answer 404, not 403, to anyone but the owner, so a guess cannot confirm existence; unlisted pages send X-Robots-Tag: noindex and never appear in the public feed or sitemaps; and every paste page sends Referrer-Policy: no-referrer, so clicking a link inside a paste does not hand its URL to someone else’s logs. Rate limits on 404s still run, to keep scanners off origin capacity and to raise an alarm, not as the defence.

Why not just use UUIDs?

A random UUIDv4 has 122 random bits, which is plenty, and would be fine; it is 36 characters in hex with dashes, or 22 in base64url. Sixteen base62 characters give 95 bits in a shorter link. The ones to avoid are UUIDv1 and v7, which embed a timestamp (and in v1 a MAC address) and so shrink the space an attacker has to search.

Someone posts an unlisted link on a public forum. Now what?

Then it is public in practice, and no id design can help. Give the owner a rotate-link action: copy the paste to a new id, delete the old one and purge the CDN, so the leaked URL stops working. And keep the product honest: the UI says unlisted means anyone with the link, not private.

What leaks unlisted URLs other than guessing?

Referer headers when a reader clicks a link inside the paste, which no-referrer fixes. Chat apps and email scanners that fetch links and sometimes index them. Browser history sync and extensions. Our own CDN logs, which contain every URL, so access to them is restricted and they are kept briefly. And search engines, if the page is ever linked from somewhere they crawl, which is what noindex is for.

How do you notice someone scanning ids?

Every id has a fixed length and alphabet, so a 404 for a well-formed id is almost always a guess; real links are copied, not typed. Count those per /24, per /64 and per ASN, and a sustained rate earns a challenge or a block at the edge. The space already makes success hopeless, so the goal is protecting origin capacity and spotting who is trying.

Do public pastes need 95 bits?

Not for secrecy, since they are listed. One format is still simpler and safe in every state a paste can be in, and a short id buys almost nothing on a site whose links are copied and pasted rather than typed.

Expiry, done twice

A paste was set to expire in 10 minutes. How do you guarantee nobody can read it at minute 11, and that its bytes are actually gone?

Two requirements hide in “expiry”. Correctness: an expired paste is never served, by the origin, the CDN or a browser. Cleanup: its row and bytes are reclaimed, about 5 M rows and 0.5 M blobs a day at steady state, without anything scanning 9 B rows to find them. A sweeper alone fails the first, because it always runs late: a 10-minute paste swept every 5 minutes is readable for up to 15. Native TTL features fail it too: DynamoDB documents that expired items are typically deleted within a few days, and until then reads still return them.

So split the jobs. Correctness is a comparison on every read, which costs nothing because the row is read anyway, plus cache lifetimes that can never outlive the paste. Cleanup is a background sweep over an index ordered by expiry time, with object storage lifecycle rules as a backstop that works even when our sweeper does not.

  • A sweeper only rejected
  • The store’s native TTL (DynamoDB TTL, Cassandra TTL) situational: the store hides expired rows on read, as Cassandra does, and the TTL deletions arrive on a change stream that can drive blob cleanup
  • Check on every read, sweep an hourly expiry index, lifecycle rules as backstop chosen
  • Delete on read only rejected

The answer: Every row carries expires_at, and every read compares it with the clock before anything else: expired means 410, whether or not the row still exists. Cache lifetimes are capped at the remaining life (CDN-Cache-Control max-age = min(30 days, expires_at − now), browser max-age = min(300 s, the same)), so the edge and browsers drop their copies at expiry by themselves and expiry never needs a purge. For cleanup, the create transaction writes the id into expiry_index under (expiry hour, hash(id) mod 64). Sweeper workers lease shards, read closed hours, delete rows with a condition on expires_at, and delete blobs with batched calls of up to 1,000 keys, about 500 calls a day. Expiring blobs live under exp/1d/, exp/7d/, exp/31d/ and exp/366d/ with lifecycle rules that delete objects a day past their class, so no blob can outlive its class by more than a day whatever the sweeper does. The alert is on the age of the oldest unswept hour.

Whose clock decides expiry?

The origin’s: expires_at is computed in UTC at create time on NTP-synced servers, where skew is milliseconds. The CDN and browsers get a relative max-age, so their clocks do not matter. The worst case is a fraction of a second of grace, which is well inside what “10 minutes” promises.

The sweeper falls a day behind. What breaks?

Nothing a user can see: reads still return 410 on time and caches have already let go. Storage carries a day of extra rows and blobs, about 5 M rows, and the lifecycle rules keep deleting old blobs regardless. The oldest-unswept-hour alert fires; when the sweeper recovers it drains the backlog shard by shard, rate limited so it does not starve paste writes.

Why split each hour into 64 shards?

One partition per hour would take every expiry index write landing in that hour, about 60 a second on average and more at peak, plus every sweeper read, on one key: a hot partition in the making. Sixty-four shards by hash of the id spread the writes and let 64 workers drain an hour in parallel. Hourly buckets keep the index ordered, so the sweeper only ever touches closed hours.

An owner deletes a paste that is also in the expiry index. Is there a race?

Both paths are idempotent, so the race is harmless. The owner delete marks the row and the change stream drives cleanup; later the sweeper finds the id, its conditional delete matches no live row, and it moves on. Deleting an object that is already gone succeeds too. Idempotent deletes are what make it safe to run several cleanup mechanisms at once.

How do you prove expiry works in production?

A canary creates 10-minute pastes every minute in each region and fetches the page, raw and download URLs from several edges just after expiry; any 200 pages the on-call. Separately, track how long expired rows and exp/ blobs survive past their expiry, as a distribution, against the class limits.

Burn after reading, exactly once

Two people open a burn-after-reading link at the same moment. Who sees it, and how do you make sure it is never shown twice?

Burn after reading promises at most one reader. The naive version reads the paste, returns it and then deletes it, so two concurrent requests both read before either deletes and both see the content. Caching makes it worse: a CDN that stores the first response serves it to everyone after, and request coalescing can hand one origin response to many waiting clients. And the most common second reader is not a person: chat apps, email security scanners and browser prefetchers fetch links on their own, so a GET that reveals the content burns it before the recipient ever clicks.

The fix has three parts. Make the read and the delete one atomic operation on the paste’s key, so the store’s per-key ordering picks exactly one winner. Put the reveal behind a POST from an interstitial, so no automated GET consumes it. And mark everything on that path no-store, so no cache ever holds the content.

  • Read, return, then delete rejected
  • One conditional delete (or state flip) that returns the content, behind a POST chosen
  • A distributed lock around read-and-delete rejected
  • Allow N reads with an atomic counter situational: the product wants “viewable 3 times” rather than once

The answer: A GET on a burn paste returns only an interstitial (“this paste will be deleted after you read it”) with Cache-Control: no-store, so previewers and scanners consume nothing. Show sends POST /v1/pastes/{id}/reveal, which the CDN never caches or coalesces, and the path is excluded by a CDN rule as well as by header. For an inline paste the service runs a conditional delete that returns the deleted item (DeleteItem with a condition on state = active and ReturnValues ALL_OLD, or DELETE … RETURNING in SQL): the store applies the two requests in turn, the first gets the content, the second matches nothing and gets 410. For a blob-backed paste the same conditional write flips the state to burned and returns the blob key; the winner streams the blob, and the sweeper deletes it ten minutes later so a slow download can finish. Burn pastes are always unlisted, always expire (a week by default), and are never deduplicated or counted. If the winner’s connection dies mid-stream the content is gone: I choose at most once over at least once, because two readers is the failure the feature exists to prevent.

The winner’s connection drops halfway. They retry. Do they get it?

Not by default: the paste is burned and the retry gets 410. If users find that too harsh, the reveal can set a claim token in a cookie, valid for 60 seconds, so the same browser can download again while any other browser cannot, with the blob kept until the claim expires. That weakens “exactly once” to “one browser, briefly”, which I would only do if the support tickets justified it.

Why does the loser get 410 rather than 404?

Because the intended recipient learns something important: someone opened it first. The creator can then treat whatever was in it as compromised and rotate it. Whoever holds the link already knows the paste existed, so the 410 tells them nothing new.

Could the CDN still end up serving burn content?

There are three guards. The content only ever travels in POST responses, which CDNs pass through. Every response on the path says Cache-Control: no-store, private. And the CDN configuration bypasses the reveal path entirely, so one wrong header cannot turn it into a cached object. I would also alert if a burn paste id ever shows up as a cache hit in the CDN logs.

How do you test the race?

A test fires 100 concurrent reveal requests at one paste and asserts exactly one 200 and 99 410s, on every build, against the real store rather than a mock, because the guarantee lives in the store’s conditional write. A synthetic monitor does the same in production with throwaway pastes, through the CDN, so a configuration change that starts caching the path is caught too.

Why can a burn paste not be public?

Because the first reader of a public paste is a scraper of the public feed, seconds after it is posted. Burn only makes sense for a link handed to one person, so burn pastes are forced to unlisted and get the full 95-bit id. They also always expire, a week by default, so an unread one does not sit forever; whichever comes first, the read or the clock, ends them.

Caching text that can still disappear

Pastes never change. Why not cache them forever?

Reads outnumber writes 15 to 1 with a viral head: one paste linked from a front page can draw 20 k requests a second. Pastes are immutable (an edit makes a new paste), which is the ideal CDN workload: there is no invalidation on change because there is no change. But a paste can stop existing: it expires, its owner deletes it, or trust and safety takes it down. And some pastes, private and burn ones, must never sit in a shared cache at all.

Each way of disappearing has its own cache answer. Expiry is scheduled, so cap the lifetime at the remaining life and no purge is ever needed. Deletes and takedowns are unscheduled, so purge by a tag shared by every variant of the paste, and keep short the lifetimes we cannot purge: browsers and other people’s proxies. Private and burn content is no-store and served only by the origin.

  • CDN for public and unlisted, CDN-only long lifetimes capped at remaining life, tag purges for deletes and takedowns, no-store for private and burn chosen
  • Short lifetimes everywhere, no purge situational: the CDN has no reliable purge, or the takedown target is measured in hours
  • No CDN: origin plus a regional cache rejected
  • Cache private pastes at the edge, with auth checked by an edge function rejected

The answer: Public and unlisted responses send Cache-Control: public, max-age=min(300 s, remaining) for browsers and any proxy, and CDN-Cache-Control: max-age=min(30 days, remaining) for our CDN only, because we can purge our CDN and nobody else’s cache. Every variant (page, raw, download) carries the surrogate key p:{id}, and the sweeper purges that key on delete or takedown, inside the five-minute takedown target. Cached HTML is the same for every viewer: personal parts load from an uncached API call and the CDN leaves cookies out of the cache key. Private and burn responses are Cache-Control: private, no-store, and the CDN bypasses those paths by rule as well. A 410 or 451 is cached for 60 seconds, so purging a viral paste does not turn into 20 k origin requests a second. View counts come from the CDN log stream, since most views never reach us.

A paste goes viral. What does origin actually see?

With coalescing at each PoP and an upper tier per region, about one fetch per region per lifetime: a handful of origin requests behind 20 k a second at the edge. The dangerous moment is right after a purge, when every PoP misses at once; coalescing plus the upper tier turns that into one request per region, and a cached 451 keeps it there after a takedown.

Why serve raw text from a separate domain? It is just text/plain.

Because “just text/plain” relies on every browser honouring it. If a raw paste on the app’s own origin were ever rendered as HTML, through sniffing, a bug or a crafted request, the attacker’s script would run with the app’s cookies and could read private pastes. A separate registrable domain, the way GitHub serves user content from githubusercontent.com, is never sent our cookies and is not same-site with the app; a subdomain would be. On top of that: nosniff, an explicit charset=utf-8, and CSP default-src 'none'; sandbox, so even a rendered response can run nothing.

Syntax highlighting: client or server?

Client, in a Web Worker, for pastes up to about 1 MB; above that, plain text with line numbers. The server stores only the language, guessed at write time if the user did not choose one, and sends the text escaped inside the page, so it reads fine without JavaScript and search engines see the code. Highlighting once at write time would store HTML several times the size of the text, which then has to be purged and deleted with the paste, and a highlighter bug would become stored XSS. Highlighting on the server at read time is fine too, since the CDN caches the result; storing rendered HTML is the option I would avoid.

How do view counts work if most views never reach you?

From the CDN’s log stream: each edge request becomes a log line, a stream job counts per paste per minute, and adds the minute’s total with one write. A viral paste costs one write a minute instead of 20 k a second. The number is approximate (bots, prefetches, log lines that arrive late or not at all) and is never baked into the cached page; the page fetches it separately with a 60-second cache.

The CDN has an outage. Can origin take the traffic?

The average is 1.7 k reads a second and the peak about 5 k, and nine in ten are one key-value lookup, so a store and service sized with headroom can take the whole load for a while; that is worth load testing, not assuming. The viral head is what would hurt, so the fallback path rate limits anonymous reads per id and per network. DNS failover to a second CDN is the real fix if the business needs it.

The theory behind it

  • Object storage and large files: Blob storage, pre-signed uploads, chunking and resumable transfer, multipart, erasure coding, storage tiers and lifecycle, plus how media pipelines are structured.
  • HTTP caching and cache headers: How browsers, CDNs and proxies decide what to cache: Cache-Control directives, max-age and s-maxage, ETags and conditional requests, immutable assets with fingerprinted URLs, stale-while-revalidate, Vary, private versus shared caches, and purging.
  • Trust and safety, moderation and abuse prevention: Designing defences against spam, fake accounts, fraud and harmful content: layered checks at signup, posting and payment, rules plus machine learning, review queues, hashing known-bad media, reputation, rate limits and feedback loops.

Related

Report an issue

Something wrong with this question? A number that does not add up, a step whose answer key looks off, a typo. It goes straight to the author with the page you are on.

Watch a strong candidate answer thisTurn by turn, with the design drawn as they talkWalk through →Mock interview