Worked system design solution
Design Pastebin
A web service for sharing text snippets via short URLs. Users paste text, get a short link, and anyone with the link can read it back. We design for moderate write traffic, high read traffic via CDN, and an expiry-driven storage model with hot/warm tiering.
Requirements
Functional + non-functional scope and what we explicitly cut
Functional requirements (must-haves)
- A user can paste a text snippet (1 byte – 10 MB) and receive a short URL like
paste.io/aB3xY9. - Anyone with the URL can read the paste back — no login required for reads.
- A user can set expiry (
10min | 1hr | 1day | 1week | 1month | never); pastes auto-delete after expiry. - A user can optionally set a custom alias (
paste.io/my-meeting-notes). - A user can optionally set the paste as private (only the URL holder can read).
- A user with the owner token can delete their paste.
Non-functional requirements
- Read-heavy workload — assume 10:1 read:write ratio typical of share-links.
- Read latency: < 200 ms p95 globally (feels like static content).
- Write latency: < 500 ms p95.
- Availability: 99.95% reads, 99.9% writes (downtime budget ≈ 4 hours/year on reads).
- Durability: 11 nines on stored pastes — losing a paste once written is unacceptable.
- Eventual consistency on reads is acceptable: a freshly-created paste being unreadable for ≤ 1 s is fine.
- Abuse / spam must be rate-limited at the create endpoint.
Explicitly out of scope (v1)
- User accounts, paste history, paste versioning.
- Collaborative editing, real-time co-edit.
- Syntax highlighting (could be done client-side from a library — no server cost).
- Search across pastes.
- Folder / organization model.
Assumptions we're making (state these out loud)
- Average paste size = 10 KB (skewed by code dumps + stack traces; 95th percentile ≈ 100 KB).
- We will run for at least 5 years before re-architecting.
- Pastes are public by default but can be marked private. Private pastes don't need encryption-at-rest beyond the default S3 SSE-S3 (server-side encryption with managed keys).
Interview: Strong candidates make scope explicit before sketching boxes. The "no auth on reads" decision drives the entire CDN strategy below — surface it now or it'll bite you in step 6.
Capacity Estimation
Traffic, storage, bandwidth, and server count from first principles
Baseline (modeled on Pastebin.com at peak — public stats: ~1 M new pastes/day, ~10 M views/day):
Traffic (QPS)
| Metric | Average | Peak (3× avg) |
|---|---|---|
| New pastes | 12 / sec | 36 / sec |
| Reads | 120 / sec | 360 / sec |
| Deletes | ~1 / sec | ~3 / sec |
Even at peak, the absolute QPS is tiny. The interesting work is in storage growth, CDN bandwidth, and expiry sweeping.
Storage growth
- Write volume:
12 paste/s × 10 KB × 86,400 s/day= 10.4 GB/day of raw content. - Annual: ≈ 3.8 TB/year.
- 5-year retention (excluding expired): assume 70% of pastes are short-TTL — net long-lived growth = ~6 TB over 5 years.
- Metadata: 1 row per paste, ~500 bytes/row → ~180 GB metadata over 5 years (trivial).
Bandwidth
- Ingress (write):
12 × 10 KB= 120 KB/s ≈ 1 Mbps. Trivial. - Egress (read) at origin:
120 × 10 KB= 1.2 MB/s average; 3.6 MB/s peak. - Egress through CDN edge (where most reads land):
1200 × 10 KB= 12 MB/s at the edge if we assume the 10:1 fanout from CDN edge to origin. The CDN does the heavy lifting.
Server-count sanity check
A single modern API server can comfortably handle 5,000 QPS of small JSON requests. We need <100 QPS of API traffic at peak after the CDN absorbs reads → 2 API instances (one for redundancy) cover this baseline.
Storage:
- DynamoDB or Cassandra for metadata: on-demand mode is fine at this scale.
- S3 for blobs: literally one bucket, no capacity planning required (S3 partitions itself).
- Redis for hot-paste cache + rate limits: single 4 GB instance with read replicas.
Complexity: The numbers tell you what the bottleneck is not. At this scale it's not compute, not bandwidth, not storage. The hard parts are (a) the expiry/sweeping pipeline staying healthy and (b) the CDN hit-ratio staying high.
API Design
Full REST contract with status codes, headers, idempotency, errors, and rate limits
Three endpoints cover everything. All requests and responses are JSON. All timestamps are ISO-8601 UTC.
Common headers
| Header | Direction | Purpose |
|---|---|---|
Content-Type: application/json |
request | required on POST/PUT/PATCH |
Authorization: Bearer <jwt> |
request | optional; required for delete + owner-protected reads |
Idempotency-Key: <uuid> |
request | recommended on POST to prevent dup-create on retry |
X-Request-ID: <uuid> |
request | optional client trace id; echoed in response |
X-RateLimit-Limit |
response | requests/minute allowed for caller |
X-RateLimit-Remaining |
response | requests left in the current window |
X-RateLimit-Reset |
response | epoch second when the window resets |
1. Create a paste
POST /v1/pastes
Content-Type: application/json
Authorization: Bearer <optional>
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
{
"content": "console.log('hello world');",
"expiry": "1d",
"isPrivate": false,
"customAlias": null,
"language": "javascript"
}
Field constraints:
content(required, string): 1 ≤ length ≤ 10 MB.expiry(optional): enum10min | 1hr | 1day | 1week | 1month | never. Default:1week.isPrivate(optional, boolean): defaultfalse. Private pastes serve over signed URLs only.customAlias(optional, string): 3–60 chars,/[a-zA-Z0-9_-]+/. Must be globally unique.language(optional): hint for client-side syntax highlighting. Not validated.
Success — 201 Created:
{
"id": "aB3xY9",
"url": "https://paste.io/aB3xY9",
"rawUrl": "https://paste.io/raw/aB3xY9",
"createdAt": "2026-05-24T12:00:00Z",
"expiresAt": "2026-05-25T12:00:00Z",
"ownerToken": "0d7f...c91e",
"language": "javascript"
}
The ownerToken is returned only to the original creator and is the only credential that authorises delete. Lose it and the paste can only be cleaned up at expiry.
Error responses:
| Status | code field |
Returned when |
|---|---|---|
| 400 | content_too_large |
Body exceeds 10 MB |
| 400 | invalid_expiry |
expiry not in allowed enum |
| 409 | alias_taken |
customAlias already exists |
| 422 | alias_reserved |
Alias hits the reserved-word blocklist |
| 429 | rate_limited |
> 10 creates/minute from this IP |
| 500 | server_error |
Anything else |
Every error follows the same envelope:
{
"error": {
"code": "alias_taken",
"message": "Custom alias 'meeting-notes' is already in use.",
"requestId": "req_01HXYZ..."
}
}
2. Read a paste
GET /v1/pastes/{id}
Accept: application/json
{
"id": "aB3xY9",
"content": "console.log('hello world');",
"language": "javascript",
"createdAt": "2026-05-24T12:00:00Z",
"expiresAt": "2026-05-25T12:00:00Z",
"viewCount": 42
}
For browser readers, we also expose GET /raw/{id} returning text/plain — used by the CDN's edge function to serve the bare paste content with maximum cacheability.
Status codes:
200— paste exists and is readable.404— paste doesn't exist, expired, or was deleted.401— paste is private and the caller didn't supply the access token.410— paste exists in metadata but blob was sweeper-deleted (rare race; client should retry once and treat as 404).
3. Delete a paste
DELETE /v1/pastes/{id}
Authorization: Bearer <ownerToken>
204 No Content on success. 403 if the token doesn't match. Soft-delete: metadata row is marked deleted=true and blob is purged async (CDN purge fires within ~30 s, blob delete within ~5 min).
Rate limiting
| Endpoint | Limit / IP | Limit / authed user |
|---|---|---|
| POST /pastes | 10 / min | 60 / min |
| GET /pastes/{id} | 600 / min | unrestricted |
| DELETE /pastes/{id} | 30 / min | 30 / min |
Implemented as a token-bucket in Redis (bucket:{ip}) at the API gateway. Sliding-window with 1-minute buckets.
Idempotency
POST /v1/pastes accepts Idempotency-Key: if the same key is seen within 24 hours, we return the original response (same paste id, same URL) instead of creating a duplicate. Useful for clients that retry on network failure.
sequenceDiagram
autonumber
participant C as Client
participant API as API service
participant R as Redis (idempotency store)
participant KV as DynamoDB
C->>API: POST /pastes (Idempotency-Key: K)
API->>R: GET idem:K
alt key seen
R-->>API: existing response blob
API-->>C: 201 (cached response)
else first time
R-->>API: nil
API->>KV: create paste
API->>R: SETEX idem:K 86400 <response>
API-->>C: 201 (fresh response)
end
High-Level Design
Full system architecture diagram with every component, and the request lifecycle
Architecture
flowchart TB
subgraph Client[Client tier]
Browser["Web / Mobile / CLI client"]
end
subgraph Edge["Edge tier — globally distributed"]
DNS["Route 53<br/>Anycast DNS"]
WAF["WAF + DDoS<br/>AWS Shield / Cloudflare"]
CDN["CDN edges<br/>~250 PoPs"]
Shield["Origin Shield<br/>1 per region"]
end
subgraph API["API tier — regional"]
ALB[Application Load Balancer]
APISvc["Paste API service<br/>auto-scaling 4–40 pods"]
RateLim[Rate-limit middleware]
end
subgraph SubSvc[Sub-services]
IDGen["ID Generator<br/>Snowflake"]
Auth["Auth / Token Service"]
end
subgraph Data[Data tier]
Redis["(Redis cluster<br/>hot pastes + idempotency<br/>+ rate-limit tokens)"]
KV["(DynamoDB<br/>metadata + TTL)"]
Blob["(S3<br/>paste content)"]
end
subgraph Async[Async pipeline]
Stream[DynamoDB Streams]
Sweeper["TTL Sweeper<br/>Lambda"]
Audit["Audit log → Kinesis"]
Purger[CDN Purge worker]
end
Browser --> DNS
DNS --> WAF
WAF --> CDN
CDN --> Shield
Shield --> ALB
ALB --> APISvc
APISvc --> RateLim
RateLim --> Redis
APISvc --> Auth
APISvc --> IDGen
APISvc --> KV
APISvc --> Redis
APISvc --> Blob
APISvc --> Audit
KV --> Stream
Stream --> Sweeper
Sweeper --> Blob
Sweeper --> Purger
Component responsibilities
Edge tier
- Route 53 (or any anycast DNS): geo-routes the user to the nearest CDN PoP based on latency.
- WAF + DDoS: blocks obvious abuse (SQL injection patterns, known bad IPs, geographic blocks if needed).
- CDN edges: ~250 globally-distributed points-of-presence. Serves the paste-read response from cache when present.
- Origin Shield: a single cache instance per region that absorbs cache-miss traffic from all edges in that region. Prevents the "thundering herd" problem where a viral paste cache-misses from 250 PoPs simultaneously.
API tier
- ALB: terminates TLS, distributes to API pods, supports health checks.
- Paste API service: stateless, horizontally scaled. Each instance handles ~1,000 QPS. We run 4 baseline, auto-scale to 40 under load.
- Rate-limit middleware: in-process check against Redis token-buckets; rejects before doing real work.
Sub-services
- ID Generator: produces 7-char base62 ids. Either Snowflake-style (see the Snowflake solution) or simple counter+prefix.
- Auth service: validates JWTs; issues owner tokens on create.
Data tier
- Redis: hot pastes (top 1% by access), idempotency keys (24 h TTL), rate-limit token buckets, customAlias lock during create.
- DynamoDB: paste metadata (id, blobKey, expiresAt, ownerTokenHash, isPrivate, customAlias). Native TTL field automatically deletes expired metadata.
- S3: paste blob content. Cheap, durable (11 9's), infinitely scalable.
Async pipeline
- DynamoDB Streams: emits a change-event whenever a metadata row is created/deleted.
- TTL Sweeper Lambda: triggered by stream events. When a row's TTL fires, this Lambda deletes the corresponding S3 blob and issues a CDN purge.
- Audit log: every mutation flows to Kinesis → S3 + Athena for compliance + analytics.
Request lifecycle — write path
sequenceDiagram
autonumber
participant C as Client
participant CDN
participant API as API svc
participant RL as Redis
participant ID as ID Gen
participant KV as DynamoDB
participant S3
C->>CDN: POST /v1/pastes
CDN->>API: forward (no caching on POST)
API->>RL: check rate limit (token bucket)
RL-->>API: ok
API->>RL: check idempotency-key
RL-->>API: not seen
API->>ID: nextId()
ID-->>API: "aB3xY9"
API->>S3: PUT /pastes/aB/3x/Y9.txt
S3-->>API: ok
API->>KV: PutItem (id, blobKey, expiresAt, ownerTokenHash)
KV-->>API: ok
API->>RL: SETEX idem:K 24h <response>
API-->>C: 201 { id, url, ownerToken, expiresAt }
Request lifecycle — read path (the hot path)
sequenceDiagram
autonumber
participant C as Browser
participant CDN
participant API as API svc
participant R as Redis hot cache
participant KV as DynamoDB
participant S3
C->>CDN: GET /raw/aB3xY9
alt CDN hit (≥95% of traffic)
CDN-->>C: 200 cached content
else CDN miss
CDN->>API: forward
API->>R: GET paste:aB3xY9
alt Redis hit
R-->>API: blob bytes
else Redis miss
API->>KV: GetItem aB3xY9
KV-->>API: { blobKey, expiresAt }
API->>S3: GET <blobKey>
S3-->>API: blob bytes
API->>R: SETEX paste:aB3xY9 1h
end
API-->>CDN: 200 + Cache-Control: max-age=86400
CDN-->>C: 200 + caches for next time
end
Database Design
Hybrid blob + KV model, schema, indexes, partitioning, and consistency choices
We split data into two stores because metadata and blob content have very different access patterns and cost profiles.
Metadata KV (DynamoDB)
| field | type | notes |
|---|---|---|
id |
string | Partition key — 7-char base62 |
blobKey |
string | s3://pastes/{aa}/{bb}/{id}.txt |
expiresAt |
number | Unix epoch seconds — DynamoDB TTL attribute |
createdAt |
number | epoch seconds |
ownerTokenHash |
string | bcrypt of owner token; required for DELETE |
isPrivate |
boolean | controls read-access policy at CDN edge |
customAlias |
string? | nullable; GSI for alias → id lookup |
size |
number | byte count, for analytics |
language |
string? | client hint |
viewCount |
number | incremented async via stream |
deleted |
boolean | soft-delete tombstone (cleaned by sweeper) |
Capacity mode: on-demand. The traffic shape doesn't justify reserved capacity.
Indexes:
- PK = id: covers
GetItemon read andDeleteItemon delete — the 99.99% access path. - GSI
alias-index: PK =customAlias. Only populated whencustomAliasis set. Used to resolve vanity URLs.
Partitioning: DynamoDB hash-partitions on the PK. With 7-char base62 ids, the distribution is uniform — no hot partitions even with a viral paste.
Object store (S3)
One object per paste, keyed by a 3-level prefix for partition friendliness:
s3://my-pastes-bucket/aB/3x/aB3xY9.txt
The two leading 2-char prefixes (aB then 3x) come from the first 4 chars of the id. S3 best-practice was historically "spread your prefixes to avoid hot partitions" — modern S3 doesn't strictly need it but the convention costs nothing and makes lifecycle policies easier.
S3 features we lean on:
- Default SSE-S3: AES-256 encryption at rest with AWS-managed keys.
- Versioning OFF: pastes are immutable, no value to versions.
- Lifecycle policy: transition to S3 Standard-IA after 30 days, Glacier Deep Archive after 1 year.
- Bucket policy: only the API service role can write; reads via signed URLs for private pastes.
Consistency choices
| Operation | Consistency | Why |
|---|---|---|
| Read paste | Eventually consistent | Tolerable; 1 s lag at most |
| Create paste | Strong write | We commit S3 first, then KV. Order matters. |
| Custom alias claim | Conditional write | Compare-and-set on the alias-index GSI |
The custom-alias claim is the only place we need strong consistency. We use DynamoDB's conditional write (attribute_not_exists) so two concurrent claims for the same alias resolve to exactly one winner.
await ddb.put({
Item: { ...metadata },
ConditionExpression: 'attribute_not_exists(customAlias)',
}).promise();
// throws ConditionalCheckFailedException → return 409 to client
Short-ID scheme
7 base62 chars ([0-9a-zA-Z]) → 62^7 = 3.5 trillion keyspace. At 1 M pastes/day, collision-free for ~9,500 years with a per-server counter, or forever with a Snowflake-style timestamped ID.
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
function encode(num: bigint): string {
let s = '';
while (num > 0n) {
s = ALPHABET[Number(num % 62n)] + s;
num /= 62n;
}
return s.padStart(7, '0');
}
Deep Dive: Caching
Four-layer caching strategy with concrete TTLs and the immutability win
Pastes are immutable once written (we never edit a paste; you can only delete it). That single property is the most important fact in this design — it means we can cache aggressively at every layer without ever worrying about stale reads.
Cache topology
flowchart LR
Browser["Browser cache<br/>Cache-Control: max-age=300"] -->|miss| Edge["CDN Edge<br/>~250 PoPs<br/>TTL = min(expiry, 1d)"]
Edge -->|miss| Shield["Origin Shield<br/>1 per region<br/>TTL = 1d"]
Shield -->|miss| API[API service]
API -->|hot path| Redis["(Redis<br/>top 1% pastes<br/>LRU, 10 GB cap)"]
API -->|cold path| KV["(DynamoDB)"]
API -->|blob fetch| S3["(S3)"]
classDef cache fill:#fef3c7,stroke:#d97706,color:#000000
class Browser,Edge,Shield,Redis cache
Layer 1 — Browser cache
Set Cache-Control: public, max-age=300 on /raw responses. A user refreshing the same paste 10 times in a minute hits zero servers. TTL is short (5 min) so deletions become visible quickly without forcing a server round-trip.
Layer 2 — CDN edge
Cache key: full URL including query params. TTL: min(expiryRemaining, 86400). Headers emitted by the API:
Cache-Control: public, max-age=86400, immutable
ETag: "v1-aB3xY9"
Vary: Accept-Encoding
immutable tells modern browsers + the CDN that the resource never changes within its lifetime — saves revalidation round-trips.
For private pastes: Cache-Control: private, max-age=300. Edges don't cache. Reads still hit the origin but the access-control check happens there.
Layer 3 — Origin shield
A single shield cache per region between the edges and the origin. Critical because:
- A meme-viral paste cache-misses from 250 edges simultaneously. Without a shield, that's 250 simultaneous origin requests for the same key.
- The shield consolidates them into 1 origin request; the other 249 wait on it. This is "request coalescing".
sequenceDiagram
participant E1 as Edge PoP-1
participant E2 as Edge PoP-2
participant E3 as Edge PoP-N
participant S as Origin Shield
participant O as Origin API
par fan-in
E1->>S: GET aB3xY9 (miss)
E2->>S: GET aB3xY9 (miss)
E3->>S: GET aB3xY9 (miss)
end
S->>O: GET aB3xY9 (one request)
O-->>S: 200 + body
par fan-out
S-->>E1: 200 + body
S-->>E2: 200 + body
S-->>E3: 200 + body
end
Layer 4 — Redis (origin-side hot cache)
Pastes that miss the CDN but are accessed > 5 times in an hour get promoted into Redis. Eviction: allkeys-lru with 10 GB cap.
Why Redis here even with the CDN? Because the CDN hit-ratio is great for popular pastes but bad for the long tail. A senior engineer's blog might link to a moderately-popular paste — getting 100 reads/hour. CDN TTL = 1 day means it'll be cached eventually, but the first read after a TTL-expiry hits the origin. Redis catches these.
Invalidation
Only happens on DELETE. Pipeline:
- API sets
deleted=trueon the metadata row. - API
DEL paste:aB3xY9in Redis. - API enqueues a CDN purge request via the async pipeline (Lambda → CloudFront API).
- Sweeper deletes the S3 object.
- After all this, any cached copy at any layer will return 404 within ~30 s.
async function deletePaste(id: string) {
await ddb.update({
Key: { id },
UpdateExpression: 'SET deleted = :t',
ExpressionAttributeValues: { ':t': true },
});
await redis.del(`paste:${id}`);
await sqs.send({ MessageBody: JSON.stringify({ id, action: 'purge' }) });
}
Hit ratios in practice
| Layer | Hit ratio | Notes |
|---|---|---|
| Browser | 30% | Same user, multiple views |
| CDN edge | 90%+ | Across all users for a given paste |
| Origin shield | 5% | Catches what edges miss |
| Redis | 4% | Long-tail popular pastes |
| Origin DB+S3 | < 1% | Cold reads, sweeper cleanup, audit |
Complexity: A 90% CDN hit ratio means the origin sees 10% of total read traffic. At 99% hit ratio (achievable with origin-shield + good TTLs) the origin sees 1% — meaning your origin can be 10× smaller for the same total throughput. This is the single most important cost lever in the design.
Deep Dive: Scaling
What changes at 10×, 100×, and 1000× — and the multi-region topology
Baseline (1×) — single region
What we've described so far. Single AWS region, 4 API pods, on-demand DynamoDB, single Redis cluster, one S3 bucket. Handles ~360 QPS peak without breaking a sweat.
10× — same architecture, more replicas
flowchart LR
LB[ALB] --> A1[API pod 1]
LB --> A2[API pod 2]
LB --> A3[API pod 3]
LB --> An["…<br/>up to 40"]
A1 & A2 & A3 & An --> R["(Redis cluster<br/>3 primaries +<br/>3 replicas)"]
A1 & A2 & A3 & An --> KV["(DynamoDB<br/>on-demand)"]
What changes:
- API pods auto-scale to ~20 instances.
- Redis becomes a 3-node cluster with read replicas.
- DynamoDB on-demand handles 10× without operator action.
- No code changes, no schema changes.
100× — multi-region active-passive
flowchart TB
subgraph DNS[Route 53 latency-based routing]
DNSnode[Geographic routing]
end
subgraph Primary["us-east-1 — write primary"]
LBus[ALB] --> APIus[API fleet 60 pods]
APIus --> Rus["(Redis cluster)"]
APIus --> DDBus["(DynamoDB primary)"]
APIus --> S3us["(S3 bucket us)"]
end
subgraph Replica["eu-west-1 — reads only"]
LBeu[ALB] --> APIeu[API fleet 30 pods]
APIeu --> Reu["(Redis cluster)"]
APIeu --> DDBeu["(DynamoDB replica<br/>via Global Tables)"]
APIeu --> S3eu["(S3 replica<br/>via CRR)"]
end
DNS --> Primary
DNS --> Replica
DDBus -.->|"async replication<br/>~1s lag"| DDBeu
S3us -.->|"Cross-Region Replication<br/>~15 min lag"| S3eu
What changes:
- DynamoDB Global Tables: multi-region, multi-active replication. We use it in active-passive mode: writes go to us-east-1, reads served from nearest region.
- S3 Cross-Region Replication (CRR): every paste object replicated asynchronously to eu-west-1.
- Route 53 latency-based routing: EU users get the EU API endpoint, US users get the US one.
- Write redirection: EU API receives a POST → it forwards to US-East (or rejects with 307 Temporary Redirect). Adds ~150 ms to write latency, no impact on the much-more-frequent reads.
1000× — multi-region active-active + sharded everything
flowchart TB
subgraph US[us-east + us-west]
USTraffic[Writes + reads]
end
subgraph EU[eu-west + eu-central]
EUTraffic[Writes + reads]
end
subgraph AP[ap-south + ap-southeast]
APTraffic[Writes + reads]
end
subgraph Conflict[Conflict resolution]
Vector["CRDT / last-writer-wins<br/>on customAlias"]
end
US <--> Vector
EU <--> Vector
AP <--> Vector
subgraph DataPlane[Per-region]
Shard1["(KV shard 1<br/>id prefix aa-mm)"]
Shard2["(KV shard 2<br/>id prefix nn-zz)"]
Shard3["(KV shard 3<br/>id prefix 00-99)"]
end
What changes:
- Active-active globally: every region accepts both reads and writes. Custom-alias conflicts (the only consistency requirement) resolved by last-writer-wins with a globally-monotonic timestamp (Snowflake ids carry the timestamp natively).
- KV sharding by id prefix: 3 char prefix → 238,328 logical shards. DynamoDB handles this transparently; if we were running Cassandra, we'd shard by id manually.
- CDN tiering: introduce a regional cache layer between the edge PoPs and the origin shield. Reduces inter-region traffic.
- Per-region S3 bucket with replication to a glacier-only archive bucket for cold pastes.
Scaling specific subsystems
Expiry sweeping
- Default: DynamoDB TTL sweeps metadata rows. S3 cleanup is a separate Lambda triggered by the stream.
- At 100× we run the cleanup Lambda per region.
- At 1000× we partition the sweeper by id-prefix range across multiple Lambda concurrency groups so no one Lambda becomes a bottleneck.
Custom-alias contention
- At 100×, alias-creation is still a single conditional write — cheap.
- We add a small alias reservation API so popular alias-namers (e.g., an open-source project assigning aliases programmatically) can bulk-reserve a prefix.
Rate limit storage
- Single Redis is fine up to ~50 K RPS.
- Beyond that, partition by IP hash across N Redis nodes.
Audit log throughput
- Kinesis scales by shard. We size the shard count to peak QPS × 4 KB.
- For privacy compliance (GDPR), we encrypt PII fields in the audit record at write time.
What we'd watch as we scale
- Origin egress cost — if CDN hit ratio drops below 85%, origin egress becomes a top-3 AWS line item.
- Hot partition in DynamoDB — if a single id sees > 3 K RPS we hit DDB's partition limit. Mitigation: promote the paste to a per-region Redis pinned cache.
- Cross-region replication lag — monitored; we alert if it exceeds 10 seconds for DDB or 30 minutes for S3.
Bottlenecks & Trade-offs
Failure modes, what we accept, monitoring, and what we revisit at scale
Top failure modes
1. CDN cache poisoning by a viral paste
A paste that gets linked from Hacker News goes from 0 to 100 K req/min in 10 seconds. If our CDN cache is cold for it, every edge cache-misses to the origin shield, which cache-misses to the origin.
Mitigation: origin shield + Redis hot cache + auto-scaling on the API tier. Tested by chaos engineering: we artificially purge the CDN for a paste each week and watch the latency / error budgets stay within SLA.
2. Hot-key DynamoDB partition
A single mega-viral paste's id is one partition key — DynamoDB caps single-partition reads at ~3,000 RPS. If 10 K users want the same paste simultaneously and the CDN is cold, the origin queues up.
Mitigation: detect viral pastes (Redis access counter) and promote to a per-region S3-only fast path — the API reads directly from S3 (which has no per-key limit) and bypasses DynamoDB.
3. S3 deletion not synced with CDN purge
Sweeper deletes the S3 object before the CDN purge fires → users hit the CDN cache and get a stale paste that's gone in S3. Functionally harmless but confusing.
Mitigation: purge first, delete second. The sweeper Lambda issues the CDN purge, waits for the 30-second propagation window, then deletes the S3 object. Costs an extra 30 s of storage per paste — irrelevant at our scale.
4. Idempotency-key collision
Client retries a create with the same Idempotency-Key but different content (bug on the client). We return the original response, content gets silently dropped on the floor.
Mitigation: we hash the request body and store (idempotencyKey, bodyHash) → response. If a later request shares the key but has a different hash, we reject with 422 Unprocessable — the client gets a clear "your idempotency key was reused with different content" error.
Accepted trade-offs
| Trade-off | What we got | What we gave up |
|---|---|---|
| Eventual consistency on reads | 1-s replication lag is fine | Strictly fresh reads (we don't need them) |
| Soft-delete for 24 h | Recovery from accidental deletes | Storage cost for deleted pastes |
| No syntax highlighting server-side | CPU savings + cacheable responses | UX feature lives in the client |
| Single-language responses (UTF-8 only) | Simple content-type story | Multi-byte file uploads need explicit handling |
| Custom-alias reservation is FCFS | Simple atomic claim model | Premium-tier "I want this alias" is unhandled |
| 10 MB hard cap on paste size | Memory bounds on the API process | Users can't paste huge files (use a file-share service) |
Monitoring
API tier — Prometheus scrape:
- p50 / p95 / p99 latency per endpoint.
- Error rate per endpoint, broken down by status code.
- Active connections, in-flight requests.
CDN — CloudWatch dashboards:
- Cache hit ratio (alert if < 85% for 5 min).
- Origin bytes-out (anomaly detection).
- 5xx rate at edge.
DynamoDB — CloudWatch:
- Read / write throttling events (should be 0 in on-demand mode).
- Hot-partition metric.
- TTL deletion rate (matches paste-creation rate after the lag).
S3 — CloudWatch + access logs:
- 4xx / 5xx rate.
- Lifecycle transition counts (tiering to IA / Glacier).
Business metrics — Athena over the audit log:
- Daily active users, pastes/user, geographic distribution.
- Privacy compliance:
viewCountquery latency, GDPR-deletion request fulfillment time.
What we'd revisit at 10× growth
- Custom alias as a Postgres table instead of a DDB GSI — GSI hot-partition risk increases as the alias space densifies.
- Move blob storage to a CDN-native object store like Cloudflare R2 (no egress fees to CDN edges).
- Replace Snowflake-style ids with content-addressable hashes — content de-duplication across users. ~30% storage savings if popular code snippets are pasted repeatedly.
Interview: Strong candidates close with this kind of slide: failure modes named, mitigations spelled out, monitoring concrete, what-we'd-change-at-10× explicit. That's the maturity signal — it shows you've not just designed a system, you've operated one in your head.