Design Pastebin: Object Storage, CDN and Edge Expiry
Design Pastebin: metadata in a database, text in object storage behind a CDN, random IDs, surviving write spikes, scaling to 1B users, edge expiry and tombstones.
A developer debugging a production issue pastes 200 lines of error logs into a text box, clicks Create Paste, and gets a short URL like pb.example/a7x3k. They post it in a GitHub issue, and over the next week dozens of developers chasing the same bug open it. Behind that simple flow: generating millions of unique short URLs without collisions, storing text from 10 bytes to 1 MB, and serving it fast when reads vastly outnumber writes.
Requirements
- Store text: users upload text; the system generates a unique short URL, persists the text and returns the URL immediately for sharing.
- Retrieve text: anyone with the URL gets the content with low latency.
Out of scope: accounts, syntax highlighting, editing, search.
| Scale input | Value |
|---|---|
| Daily active users | 1M, each creating 1 paste per day |
| Read:write ratio | 100:1 (pastes are shared on forums, GitHub issues and docs) |
| Paste size | 10 KB average, 1 MB max |
| Retention | 3 months |
Write QPS = 1M pastes/day ÷ 86,400 s ≈ 12 writes/s
Reads/day = 1M pastes/day × 100 reads/paste = 100M reads/day
Read QPS = 100M ÷ 86,400 s ≈ 1,157 reads/s
Storage = 1M/day × 10 KB × 90 days ≈ 900 GB of text
Pastes = 1M/day × 90 days = 90M live pastes- Low latency: retrieval under 100 ms; users expect instant page loads.
- High durability: once stored, text must not be lost; developers paste important code.
- High availability: 99.9% uptime, at most about 43 minutes of downtime per month. See availability.
- Unlisted URLs: nobody should be able to enumerate or scrape all pastes (they contain code and configs). Key assumption: once shared, a URL may spread anywhere; deletion removes access but can't undo copies already made. The whole design rests on this.
API
| Endpoint | Request | Response |
|---|---|---|
POST /paste | { "content": "...", "expires_in": "7d" }, max 1 MB | 201 { "url": "https://pb.example/a7x3k", "delete_token": "dt_9f2…" } |
GET /{id} | path id | 200 text content, or 404 if expired/unknown |
DELETE /{id} | header X-Delete-Token: dt_9f2… | 204; the token from creation is required |
Storing text
Where should the text live?
The simplest design puts everything in one table:
CREATE TABLE pastes (
id VARCHAR(8) PRIMARY KEY,
content TEXT,
created_at TIMESTAMP
);At 1M pastes a day × 10 KB, that's 900 GB in 3 months, and big TEXT columns cause table bloat (they fragment across pages and slow queries), backup pain (dumping and restoring 900 GB takes hours) and cost (database storage costs far more per GB than object storage). So split them: the database holds metadata, [object storage](/learn/object-storage) holds content. The database stays small and fast for the queries we need (existence, expiry, analytics) while object storage keeps bulk text cheaply and durably.
Why not object storage for everything? Object stores are poor at queries: "which pastes expired?" or "how many did this IP create today?" would mean listing and scanning objects. A tiny metadata table answers those with an index, and its primary key enforces ID uniqueness.
The write path
- The request reaches the load balancer and an app server.
- The server generates a unique short ID.
- The text goes to object storage at path
/{id}. - Metadata goes to the database:
INSERT INTO pastes (id, created_at, expires_at, size_bytes). - The short URL is returned (within about 200 ms).
How do we generate unique IDs?
The ID must be unique across ~90M live pastes. See unique ID generation.
| Option | Pros | Cons |
|---|---|---|
| Auto-increment + base62 | Short, no collisions | Sequential IDs are guessable: anyone can walk a7x3k, a7x3l… and scrape every paste, breaking the unlisted requirement; also a central counter |
| Random generation ✓ | Unguessable, no coordination, trivial to implement | Rare collisions, handled by the primary key |
| Pre-generated ID pool | No collisions at request time | A key service, storage for unused keys, atomic hand-out: complexity not justified at 12 writes/s |
| Bloom filter collision check | Fast "maybe used" test before insert | Extra moving part with false positives; the primary key already does the job |
Choose random IDs with a collision check done by the database: just attempt the insert and catch the duplicate-key error, with no separate lookup. With 8 base62 characters there are 62^8 ≈ 218 trillion IDs, so with 90M pastes the chance a new random ID collides is about 90M / 218T ≈ 0.00004%. Retries are vanishingly rare, and IDs can't be enumerated.
import secrets, string
ALPHABET = string.ascii_letters + string.digits # base62
def create_paste(db, storage, content: bytes, expires_at) -> str:
for _ in range(5): # collisions are ~0.00004%: retries are rare
paste_id = "".join(secrets.choice(ALPHABET) for _ in range(8))
try:
db.execute("INSERT INTO pastes (id, created_at, expires_at, size_bytes) "
"VALUES (%s, now(), %s, %s)", (paste_id, expires_at, len(content)))
break # the PRIMARY KEY guaranteed uniqueness
except UniqueViolation:
continue
else:
raise RuntimeError("could not allocate an id")
storage.put_object(Bucket="pastes", Key=paste_id, Body=content,
ContentType="text/plain; charset=utf-8",
CacheControl="public, max-age=86400") # immutable content: cache at the edge
return f"https://pb.example/{paste_id}"CREATE TABLE pastes (
id VARCHAR(8) PRIMARY KEY, -- "a7x3k…"
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP, -- NULL = never expires
size_bytes INT NOT NULL -- analytics, rate limiting
);
CREATE INDEX idx_expires ON pastes (expires_at); -- for the cleanup jobJust four columns: the text itself lives in object storage at /{id}, matching the URL path. The database stays fast and backups stay quick.
Retrieving text
Without caching, each read parses the ID, queries the database, checks expires_at, fetches the object and returns it, and with a 100:1 ratio the same immutable paste is fetched from origin about 100 times. Immutable content read many times is exactly what caches are for.
| Option | Verdict |
|---|---|
| In-memory cache (Redis) for full content | Not recommended: multi-KB blobs eat expensive RAM; memory caches suit small, hot data |
| In-memory cache for metadata only | Useful: fast "does this paste exist / is it expired?" checks that protect the database from spikes |
| CDN for content ✓ | Purpose-built for static blobs; immutable pastes are ideal for edge caching; object storage + CDN is a battle-tested pattern. See CDN |
How does pb.example/a7x3k reach the CDN? An app-server redirect to a CDN URL adds a round trip; returning a CDN URL directly exposes a second domain; putting the CDN in front of the main domain ✓ gives one clean domain with no redirect. Configure two origins with path routing:
- Origin A, object storage:
/*(paste content). - Origin B, load balancer/app servers:
/api/*(writes and API).
On a CDN hit, the edge returns the paste immediately; on a miss, it pulls from object storage, caches it and returns it (pull-through). Most pastes are read a few times soon after creation and then forgotten, so hot pastes stay cached and cold ones age out. Content is immutable, so long cache lifetimes (Cache-Control: public, max-age=…) are safe.
Handling expired pastes
With CDN-only reads, nothing checks expires_at per request. A nightly cleanup job finds expired pastes, deletes them from the database and object storage (stored objects cost money), and invalidates their CDN entries. Afterwards, requests get 404 from object storage. Until it runs, an expired paste may still be served, which is acceptable for most uses. Edge functions or signed URLs could check expiry per request, but each adds per-request lookups or key management; the cleanup job exists anyway for storage cost.
-- Cleanup job, 3 a.m.: a 1-day buffer for timezone edge cases and a grace period
DELETE FROM pastes WHERE expires_at < NOW() - INTERVAL '1 day' RETURNING id;
-- then batch-delete those object keys and purge them from the CDNDeep dive: surviving a write spike
A major cloud outage sends thousands of developers to paste error logs at once: writes jump from 12/s to 1,200/s, 100×. App servers are stateless, so auto-scaling adds instances within minutes. The database is the bottleneck: with a pool of 100 connections and 50 ms per write, 1,200 writes/s need 60 connections busy continuously; the pool exhausts, requests queue, timeouts cascade, and users see "Service Unavailable". See connection pooling.
| Option | Verdict |
|---|---|
| Bigger database (scale up) or shard now | Paying for 100× capacity that's idle 99.9% of the time; sharding for a 12 writes/s baseline is massive over-engineering |
| Message queue buffering ✓ | Users need a URL now, not synchronous persistence: generate the ID, enqueue the write, return the URL; workers drain the queue at a rate the database can take |
| Reject with backpressure (429) | Protects the system but fails users exactly when they need the service; keep it only as a safety valve |
Recommendation: queue buffering with rate limiting as a safety valve. Normal traffic writes straight through with low latency; a 100× spike queues and drains while users still get URLs instantly; if queue depth passes a threshold, rate limiting returns 429 so the queue can't grow without bound. (Because the paste isn't stored for a few seconds, the first view may need a short "processing" retry.) See message queues and backpressure.
Deep dive: 100M or 1B users
| Scale | DAU | Write QPS | Read QPS | Storage (3 months) |
|---|---|---|---|---|
| Current | 1M | 12/s | 1,157/s | 900 GB |
| 10× | 10M | 116/s | 11,574/s | 9 TB |
| 100× | 100M | 1,157/s | 115,740/s | 90 TB |
| 1000× | 1B | 11,574/s | 1.16M/s | 900 TB |
- 10× (10M DAU): expand CDN edge coverage and cache capacity; add read replicas and a metadata cache cluster. Writes still fit one primary.
- 100× (100M DAU): ~1,200 writes/s sustained saturates one primary: shard the metadata database by paste ID (random IDs spread evenly). See sharding.
- 1000× (1B DAU): deploy multiple regions (writes to the nearest region, replicated metadata, object storage replication) and tier storage (hot/warm/cold) for 900 TB. See geo-replication.
| Bottleneck | Threshold | Fix |
|---|---|---|
| Content latency | Users on several continents | More CDN edge locations |
| Metadata read throughput | One cache node saturated | Add cache nodes (clusters scale out) |
| Write throughput | One DB primary saturated | Shard the database |
| Regional latency | Users far from one region | Multi-region deployment |
| Storage cost | 100+ TB | Tiered storage |
Don't build for scale you don't have: at 1M DAU, one database plus a CDN and no sharding is right. Add each piece when its bottleneck actually appears.
Deep dive: expiring a paste immediately
A developer pastes database credentials by mistake and sets the paste to expire now, but the cleanup job won't run for 15 hours, and the read path (CDN → object storage) has no component that checks expires_at. Routing every read through app servers would defeat the CDN. The answer is edge compute: a small function at each CDN location runs before the request reaches origin.
| Where the edge gets expiry data | Verdict |
|---|---|
| Query the database from the edge | A round trip to the backend on every read: slow, and the database takes all the load again |
| Embed expiry in signed URLs | ?expires=…&sig=… needs key management, and expiry can't change after sharing (so "expire now" is impossible) |
| Edge key-value store ✓ | Sub-millisecond lookups at the edge, replicated globally, no backend hit |
// Cloudflare Workers-style edge function
export default {
async fetch(request, env) {
const id = new URL(request.url).pathname.slice(1);
const entry = await env.PASTE_STATE.get(id, "json"); // edge KV, sub-ms
if (entry === null) return fetch(request); // unknown here: let origin decide
if (entry.state === "GONE") return new Response("Gone", { status: 410 });
if (entry.expires_at && Date.parse(entry.expires_at) <= Date.now())
return new Response("Not found", { status: 404 });
return fetch(request); // active: CDN cache, then object storage
},
};Missing entries (propagation delay for a brand-new paste, or very old pastes whose entries were cleaned up) go to the app server, which is safe: new pastes get little traffic at first, and old ones either 404 (content gone) or are served. CDN negative caching (cache 404s for 60 seconds) plus request coalescing (simultaneous misses share one origin fetch) keep even a burst of requests for a missing ID cheap.
The write path gains a step: generate a random ID → insert into the database (PK ensures uniqueness) → write content to object storage → write `id → {state: ACTIVE, expires_at}` to edge KV (typically propagates globally in under ~50 ms) → return the URL. The cleanup job becomes housekeeping only (reclaiming storage, pruning rows): edge KV is now the source of truth for expiry, so even if cleanup fails for a week, expired pastes still return 404.
Deep dive: deleting a paste: why tombstones
Edge KV stores state (ACTIVE/GONE, expiry), not content. Simply deleting the KV entry on delete causes two problems:
- Thundering herd: a viral paste cached at 200 edge locations is deleted; after the CDN purge, all 200 edges miss, find no KV entry (they can't tell "deleted" from "never existed" or "not propagated yet"), and forward to the app server, which hammers the database with "does a7x3k exist?". See cache failure modes.
- ID reuse: if every record of the paste disappears, the generator could someday reissue
a7x3k. Unlikely with 218 trillion IDs, but then stale CDN copies and old bookmarks would show different content.
Tombstones fix the first: update the KV entry to {state: "GONE"} instead of deleting it. The edge finds the tombstone and returns 410 Gone immediately: no app server, no database query, even under millions of requests. Keep tombstones only while traffic is high: traffic to deleted content dies down within 24–72 hours, edge KV has capacity limits, so give tombstones a ~72-hour TTL; afterwards negative caching and coalescing handle stragglers. For ID reuse, keep just the `id` row in the database permanently (no content, no metadata): the primary key keeps rejecting that ID forever, and an ID string alone reveals nothing, so privacy holds.
| Storage | After deletion | Kept for | Why |
|---|---|---|---|
| Object storage | Content deleted | Within the privacy SLA | Privacy compliance |
| Edge KV | Tombstone {state: GONE} | ~72 hours (TTL) | Temporary backend protection |
| Database | id only (state = GONE) | Permanently | Prevent ID reuse |
After 72 hours the tombstone expires: a request misses edge KV, goes to origin, finds no object and gets 404, which the CDN caches for 60 seconds; by then traffic has died down, so there's no herd. Why 410 rather than 404? 404 is ambiguous (typo or never existed); 410 Gone says it existed and was removed on purpose, and is safe to cache indefinitely. Privacy-sensitive services may prefer 404 for everything so they never confirm a paste existed; tombstones still prevent the herd either way.
Where it’s used
Share logs, snippets and configs by unguessable URL.
Attach large logs as objects with metadata rows and expiry.
The same metadata-DB + object-storage + CDN pattern for any immutable upload.
The edge-KV and tombstone ideas apply to revoking any CDN-served content.
In an interview
- Split metadata (small DB rows) from content (object storage) and serve reads through a CDN.
- Use random 8-character base62 IDs so URLs can't be enumerated; let the primary key catch collisions.
- Absorb write spikes with a queue and rate limiting instead of over-provisioning the database.
- For immediate expiry or deletion, enforce at the edge with an edge KV and tombstones, and keep the id row to prevent reuse.