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.

Intermediate 10 min read · Updated

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

  1. Store text: users upload text; the system generates a unique short URL, persists the text and returns the URL immediately for sharing.
  2. Retrieve text: anyone with the URL gets the content with low latency.

Out of scope: accounts, syntax highlighting, editing, search.

Scale inputValue
Daily active users1M, each creating 1 paste per day
Read:write ratio100:1 (pastes are shared on forums, GitHub issues and docs)
Paste size10 KB average, 1 MB max
Retention3 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

EndpointRequestResponse
POST /paste{ "content": "...", "expires_in": "7d" }, max 1 MB201 { "url": "https://pb.example/a7x3k", "delete_token": "dt_9f2…" }
GET /{id}path id200 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.

/api/*/*PUT contentUsersCDNedge cache, path routingLoad balancer + app serversstateless, auto-scaledObject storagecontent at /{id}CachemetadataDatabaseid, created_at, expires_at, size
Pastebin architecture: the CDN serves content from object storage at the edge; app servers handle writes, storing metadata in the database (cached) and content in object storage.

The write path

  1. The request reaches the load balancer and an app server.
  2. The server generates a unique short ID.
  3. The text goes to object storage at path /{id}.
  4. Metadata goes to the database: INSERT INTO pastes (id, created_at, expires_at, size_bytes).
  5. 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.

OptionProsCons
Auto-increment + base62Short, no collisionsSequential 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 implementRare collisions, handled by the primary key
Pre-generated ID poolNo collisions at request timeA key service, storage for unused keys, atomic hand-out: complexity not justified at 12 writes/s
Bloom filter collision checkFast "maybe used" test before insertExtra 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 job

Just 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.

OptionVerdict
In-memory cache (Redis) for full contentNot recommended: multi-KB blobs eat expensive RAM; memory caches suit small, hot data
In-memory cache for metadata onlyUseful: 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 CDN

Deep 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.

OptionVerdict
Bigger database (scale up) or shard nowPaying 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

ScaleDAUWrite QPSRead QPSStorage (3 months)
Current1M12/s1,157/s900 GB
10×10M116/s11,574/s9 TB
100×100M1,157/s115,740/s90 TB
1000×1B11,574/s1.16M/s900 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.
BottleneckThresholdFix
Content latencyUsers on several continentsMore CDN edge locations
Metadata read throughputOne cache node saturatedAdd cache nodes (clusters scale out)
Write throughputOne DB primary saturatedShard the database
Regional latencyUsers far from one regionMulti-region deployment
Storage cost100+ TBTiered 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 dataVerdict
Query the database from the edgeA 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
2. lookupactiveexpired / GONEGET /a7x3kEdge functionEdge KVa7x3k → {ACTIVE, expires_at}3a. CDN cache / object storage3b. 404 / 410 at the edgeorigin untouched
Edge expiry check: the edge function looks the ID up in edge KV; active pastes continue to the CDN cache/object storage (3a), expired or deleted ones are answered at the edge (3b).
// 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.

StorageAfter deletionKept forWhy
Object storageContent deletedWithin the privacy SLAPrivacy compliance
Edge KVTombstone {state: GONE}~72 hours (TTL)Temporary backend protection
Databaseid only (state = GONE)PermanentlyPrevent ID reuse
UserApp serverDatabaseObject storageEdge KV / CDNDELETE /a7x3k + delete_token1. state = GONE, keep id2. delete content3. tombstone (TTL 72 h)4. purge cache (best effort)5. 204 deleted
The delete flow: mark the row GONE, delete the content, write an edge tombstone with a 72-hour TTL, purge the CDN, confirm.

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

Pastebin, GitHub Gist, hastebin

Share logs, snippets and configs by unguessable URL.

Error reporting tools

Attach large logs as objects with metadata rows and expiry.

File-sharing links

The same metadata-DB + object-storage + CDN pattern for any immutable upload.

Signed download links

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.