Design a Pastebin
Where the body lives, why expiry has to be enforced on read, and how to serve large text without paying for it twice.
PUT s3://bucket/pastes/{id} + INSERT INTO pastes (id, storage_key, size_bytes, expires_at)A pastebin stores a block of text under a short key and serves it back, usually with an expiry and sometimes with syntax highlighting. It looks like a URL shortener with a bigger payload, and that payload is the whole design problem.
Row or blob
Keep the metadata — id, owner, size, syntax, created and expiry timestamps, storage key — in a relational row, and put the body in object storage once it is more than a few kilobytes. Three reasons. A multi-megabyte column pollutes the buffer pool so unrelated queries slow down; it lands in every backup and every replica stream; and it cannot be served by a CDN, whereas an object can be handed out directly through a signed URL. Below a few kilobytes, inlining the body in the row is genuinely cheaper than a second network round trip, so most real systems do both and switch on size.
Expiry
Expiry has two halves that people collapse into one. Visibility is enforced on read: the handler compares expires_at to now and returns 404. Reclamation is a background concern — an object-storage lifecycle rule, or a batch delete. S3 lifecycle expiration is evaluated once a day and deletion is asynchronous after that, so the bytes outlive the deadline. If the read path trusts the cleanup job, expired pastes stay readable for hours.
Serving cheaply
Paste bodies are immutable, so a variant URL can carry Cache-Control: public, max-age=31536000, immutable and never be purged. Let the CDN compress text at the edge rather than storing a compressed blob you then have to decode to search or highlight.
Example
// Create path. Metadata goes to Postgres, the body goes to object storage
// once it is big enough to be worth a second round trip.
import { PutObjectCommand } from "@aws-sdk/client-s3";
const INLINE_LIMIT = 4 * 1024; // under this, the body lives in the row
const MAX_BYTES = 5 * 1024 * 1024; // hard cap, enforced before any write
async function createPaste({ body, syntax = "text", ttlSeconds, ownerId }) {
const bytes = Buffer.byteLength(body, "utf8");
if (bytes === 0) throw new HttpError(400, "empty paste");
if (bytes > MAX_BYTES) throw new HttpError(413, "paste too large");
const id = nanoid(10);
const expiresAt = ttlSeconds
? new Date(Date.now() + ttlSeconds * 1000)
: null; // null means "never"
let storageKey = null;
let inlineBody = null;
if (bytes <= INLINE_LIMIT) {
inlineBody = body;
} else {
storageKey = `pastes/${id}`;
// Write the object BEFORE the row. An orphaned object costs fractions of
// a cent; a row pointing at a missing object is a 500 on every read.
await s3.send(new PutObjectCommand({
Bucket: "pastes-prod",
Key: storageKey,
Body: body,
ContentType: "text/plain; charset=utf-8",
}));
}
await db.none(
`INSERT INTO pastes
(id, owner_id, syntax, size_bytes, inline_body, storage_key, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[id, ownerId, syntax, bytes, inlineBody, storageKey, expiresAt]
);
return { id, bytes, expiresAt };
}When to use it
- Sharing a 2 MB stack trace in a support thread without pushing it through the application tier.
- Giving users a 24-hour self-destructing paste for a log file that contains customer data.
- Keeping a metadata table small enough that 'my pastes' loads in a single indexed query.
- Serving a popular paste from the CDN so a link posted to a busy forum does not touch your origin.
More examples
Read path that owns expiry
The listing query never selects the body, and expiry is decided here rather than by whatever deleted the bytes. Use this shape any time a TTL is user-visible: the only clock you can trust is the one on the request you are answering.
// Two column lists, because this table carries the body of small pastes.
// The single-paste read needs inline_body; a listing must never select it,
// and `SELECT *` would pull it into every row of every listing query.
// (inline_body is capped at INLINE_LIMIT, 4 KB - anything larger lives in
// object storage, so the column itself is never multi-megabyte.)
const READ_COLS =
"id, owner_id, syntax, size_bytes, inline_body, storage_key, expires_at";
const LIST_COLS =
"id, owner_id, syntax, size_bytes, storage_key, expires_at";
async function readPaste(id) {
const row = await db.oneOrNone(
`SELECT ${READ_COLS} FROM pastes WHERE id = $1`, [id]
);
if (!row) throw new HttpError(404, "not found");
// Expiry is enforced here, not by the cleanup job. The job may not have run.
if (row.expires_at && row.expires_at <= new Date()) {
throw new HttpError(404, "not found");
}
if (row.inline_body !== null) {
return { ...row, body: row.inline_body }; // spread; same as
// Object.assign({}, row, {...})
}
// Large bodies are not proxied through the app tier at all: hand the client
// a short-lived signed URL and let object storage or the CDN send the bytes.
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: "pastes-prod", Key: row.storage_key }),
{ expiresIn: 300 }
);
return { ...row, bodyUrl: url };
}
// "My pastes" and anything else that returns many rows uses LIST_COLS, so a
// listing's cost does not depend on how big the pastes are. Expiry is applied
// here too, for the same reason as above.
async function listPastes(ownerId, limit = 50) {
return db.manyOrNone(
`SELECT ${LIST_COLS} FROM pastes
WHERE owner_id = $1 AND (expires_at IS NULL OR expires_at > now())
ORDER BY created_at DESC
LIMIT $2`,
[ownerId, limit]
);
}Reclaiming bytes in two layers
A lifecycle rule handles the common case for free; the batch job catches pastes whose TTL does not match any rule and keeps the metadata table from growing without bound. Reach for both when storage cost matters and when a deletion request has to be provably complete.
// Layer 1: a bucket lifecycle rule, applied once per prefix. Evaluated about
// once a day, so it is cost control, not a correctness mechanism.
const lifecycle = {
Bucket: "pastes-prod",
LifecycleConfiguration: {
Rules: [{
ID: "expire-ephemeral-pastes",
Status: "Enabled",
Filter: { Prefix: "pastes/ephemeral/" },
Expiration: { Days: 30 },
}],
},
};
// Layer 2: a batch job for everything with an explicit expires_at. Deletes in
// bounded chunks so one long-running statement cannot lock the table.
async function reapExpired(limit = 1000) {
const rows = await db.manyOrNone(
`DELETE FROM pastes
WHERE id IN (
SELECT id FROM pastes
WHERE expires_at IS NOT NULL AND expires_at <= now()
ORDER BY expires_at
LIMIT $1
)
RETURNING id, storage_key`,
[limit]
);
const keys = rows.map(r => r.storage_key).filter(Boolean);
// DeleteObjects takes at most 1000 keys per call.
for (let i = 0; i < keys.length; i += 1000) {
await s3.send(new DeleteObjectsCommand({
Bucket: "pastes-prod",
Delete: { Objects: keys.slice(i, i + 1000).map(Key => ({ Key })) },
}));
}
return rows.length;
}Immutable variant URLs for highlighted output
Syntax highlighting is expensive and deterministic, so it belongs in a cached derivative rather than on every read. Because the key includes a content hash and a renderer version, the URL can be cached for a year and a renderer upgrade simply produces a different key.
import { createHash } from "node:crypto";
const RENDERER_VERSION = "hl-3"; // bump this and every URL changes
function variantKey(body, syntax) {
const digest = createHash("sha256").update(body).digest("hex").slice(0, 16);
// Content-addressed: identical pastes share one rendered object.
return `render/${RENDERER_VERSION}/${syntax}/${digest}.html`;
}
async function highlightedUrl(paste) {
const key = variantKey(paste.body, paste.syntax);
if (!(await objectExists("pastes-prod", key))) {
const html = highlight(paste.body, { language: paste.syntax });
await s3.send(new PutObjectCommand({
Bucket: "pastes-prod",
Key: key,
Body: html,
ContentType: "text/html; charset=utf-8",
// Safe because the key changes whenever the bytes or renderer change,
// so this object never needs a CDN purge.
CacheControl: "public, max-age=31536000, immutable",
}));
}
return `https://cdn.example.com/${key}`;
}
Discussion