Design Pastebin
Run this for someone else. You hold the answers; they do not. Read the prompt, keep the clock, and use the probes below when an answer is thin. Do not show them this page.
Open with this
Store and serve 10 M text pastes a day behind unguessable links: expiry that is never late, burn after reading, and abuse takedown. Take a couple of minutes on requirements, then we will do some numbers, then the design. I will interrupt to keep us moving.
The clock
- 4 min: functional requirements and scope
- 4 min: non-functional requirements, with numbers
- 5 min: back-of-envelope estimates
- 16 min: high-level design and one or two flows
- 16 min: deep dives and the close
Move them on out loud when a section overruns. The commonest failure is spending twenty minutes on requirements and never reaching a deep dive, and preventing that is your job as much as theirs.
Requirements · 8 min
Listen for: a scoped set of capabilities, an explicit out-of-scope list, and numeric targets rather than adjectives. Prompt with “what are you not building?” if they never scope, and “what number would make that requirement real?” if they say “fast” or “highly available”.
Functional (9)
- 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 (8)
- 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.
Estimates · 5 min
Ask for two or three numbers, not all of them. What matters is whether they state assumptions, round sensibly, and say what the number implies. Push once with “where did that come from?”
The numbers (8)
- 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.
High-level design · 16 min
Let them draw. Interrupt only to ask what backs a component or what a box actually does. Then pick one flow below and ask them to walk it end to end.
Components (12)
- 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.
Flows to ask them to walk (5)
- 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.
- Client posts the text with its options.
- Paste service checks the rate limits for this IP and this account.
- Service validates the text, hashes it and checks the hash against the takedown blocklist.
- Service compresses with zstd; 8 KB or less stays inline, anything larger is written to object storage first.
- Service draws a random id and writes the row, the owner index and the expiry index in one transaction.
- Service returns the links; an anonymous creator gets a delete token, once.
- The change stream publishes the new paste and trust and safety scans it in the background.
- 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.
- Browser requests the paste page and the CDN serves it from cache.
- On a miss, the CDN asks the paste service, which reads the row by id.
- Service applies the read checks: live state, not expired, and visible to this caller.
- For a paste above 8 KB, the service streams the content from object storage.
- Service returns the page with cache lifetimes capped at the paste’s remaining life.
- A script fetches the raw link; the raw host resolves it through the paste service and returns plain text.
- The CDN’s access logs feed the view counter, which adds each paste’s views to its counter once a minute.
- 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.
- A public paste full of email and password pairs is linked from a large forum and draws 20 k requests a second.
- Each PoP misses once; coalescing and the CDN’s upper tier turn that into a handful of origin reads.
- Readers report it; reports are written to the store and reach trust and safety on the change stream.
- Trust and safety checks reputations and takes the paste down: the row flips to taken_down and its hash joins the blocklist.
- The takedown reaches the sweeper on the change stream, which purges every cached variant by surrogate key.
- The next requests miss and get a 451, cached briefly at the edge; an exact re-paste is refused by the blocklist.
- The content is kept under a legal hold for the period policy requires, then the sweeper deletes it.
- 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.
- The link to a burn paste is shared in a chat app, whose preview bot fetches it.
- Two people click Show at the same moment; both POSTs pass through the CDN to the paste service.
- Each request runs one conditional delete that returns the deleted row; only the first matches.
- For a burn paste above 8 KB, the winner flips the state to burned and streams the blob.
- The winner gets the text with no-store; the other reader gets 410 “already read”.
- The change stream carries the burn to the sweeper, which removes the leftovers.
- 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.
- A one-hour paste is requested at minute 61, before the sweeper has reached it.
- Every minute, the sweeper reads the expiry index buckets for hours that have closed.
- Sweeper deletes the expired rows with a condition on expires_at.
- Sweeper deletes the expired blobs in batches of up to 1,000 keys.
- An owner deletes a never-expiring public paste; the row is marked deleted and the change reaches the sweeper.
- Sweeper purges the CDN by surrogate key and decrements the shared blob’s reference count.
- A garbage collection pass deletes shared blobs whose count has been zero for 24 hours.
Deep dives · 16 min
Pick two. Ask the headline question, let them answer, then use the follow-ups. The follow-ups are where the level gets decided, so leave time for at least three of them.
Inline below 8 KB, object storage above
Ask: Why not put every paste in object storage, the way Dropbox stores files? Or every paste in the database?
Good answers name: Inline when the compressed text is 8 KB or less, object storage above, Everything in object storage, metadata in the database, Everything inline in the database, Haystack-style packed volumes we run ourselves.
Our pick: 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
Ask: Thousands of people paste the same log or the same leaked dump. Why not store each unique content once, globally?
Good answers name: Global dedupe that returns the existing paste, Global dedupe of blobs, with separate ids, Dedupe within a scope: all public pastes together, each signed-in owner’s pastes together, No dedupe; keep the hash only for the blocklist.
Our pick: 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
Ask: The URL shortener used 7-character codes. Why are those not good enough for unlisted pastes?
Good answers name: Shortener-style codes: a ranged counter with a scramble, 7 or 8 characters, 16 base62 characters from a CSPRNG (~95 bits), Short public id plus a separate secret token for unlisted pastes, Decryption key in the URL fragment, encrypted in the browser.
Our pick: 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
Ask: 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?
Good answers name: A sweeper only, The store’s native TTL (DynamoDB TTL, Cassandra TTL), Check on every read, sweep an hourly expiry index, lifecycle rules as backstop, Delete on read only.
Our pick: 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
Ask: 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?
Good answers name: Read, return, then delete, One conditional delete (or state flip) that returns the content, behind a POST, A distributed lock around read-and-delete, Allow N reads with an atomic counter.
Our pick: 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
Ask: Pastes never change. Why not cache them forever?
Good answers name: 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, Short lifetimes everywhere, no purge, No CDN: origin plus a regional cache, Cache private pastes at the edge, with auth checked by an edge function.
Our pick: 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.
Close · 5 min
Ask what breaks first at ten times the load, and what they would build next. Then give them your read: one thing that was strong, one thing that was missing, one thing to practise. Be specific; “good job” helps nobody.