All cloud projects
LIVE us-east-1 2026
CLOUD PROJECT · 2026

place — a live collaborative pixel canvas

A shared 128x128 canvas anyone can draw on, live. Pick a colour, click a pixel, and everyone connected watches it appear in real time. r/place in miniature: API Gateway WebSockets, a broadcast fan-out Lambda, a per-IP cooldown, and a hard connection cap — the real-time serverless stack almost no portfolio actually ships, on its own subdomain so the main site's CSP never bends.

API Gateway WebSocketLambdaDynamoDBCloudFrontS3

The problem

Almost every cloud portfolio is request/response: a page, an API, a form. Real-time — a persistent connection, state shared live across strangers — is the thing that’s hard and that nobody ships. So I built the honest test of it: a shared pixel canvas. Anyone can place a pixel, and everyone connected sees it the instant it lands. It forces every real-time question into the open — connection management, broadcast fan-out, abuse control, and how to keep a live socket from wrecking a security posture I care about.

The architecture

An API Gateway WebSocket API holds the persistent connections. One Lambda handles the whole protocol, routed by requestContext.routeKey:

All state lives in one DynamoDB table, partitioned by kind: connection registry, canvas rows, and per-IP cooldown markers. A scheduled Lambda flattens the canvas to a single JSON snapshot in S3, so a cold page load is one GET instead of replaying history. The page itself is static, on its own CloudFront distribution.

browsers(N clients)API GatewayWebSocketdraw LambdarouteDynamoDBpixel + cooldownS3 snapshotevery 5 minbroadcast pixel to all connections
Browsers hold a WebSocket to API Gateway. A draw routes to one Lambda: it takes the per-IP cooldown, writes the pixel atomically to DynamoDB, then fans the pixel out to every connection via post_to_connection. A scheduled snapshot Lambda writes canvas.json to S3 for cold loads.

Key decisions & trade-offs

One Lambda, routed by routeKey. Three separate functions for connect/disconnect/draw is the tutorial shape, but it triples the cold starts and scatters the shared state logic. One function routed on requestContext.routeKey keeps the whole protocol — registry, cooldown, broadcast — in one readable place.

Atomic single-pixel writes, no read-modify-write. The naive canvas store is one big blob you read, mutate one byte of, and write back — which loses pixels under concurrent draws. Instead each canvas row is a DynamoDB list and a draw is SET px[i] = colour: a single atomic update of one index, no read, no lost-update race. The row is lazily created blank on its first pixel.

Per-IP cooldown as a conditional put. The r/place rule — one pixel every few seconds — is a DynamoDB conditional write on a per-IP marker (attribute_not_exists OR expired). It’s the same primitive I used for the chaos-drill lock: the database enforces the rate limit atomically, so there’s no window to race. Abuse test: 256 rapid draws from one IP landed exactly 2 pixels over 5 seconds.

A hard connection cap. Broadcast fan-out is O(connections), and connections are the cost driver. $connect refuses past a ceiling, which bounds both the per-draw fan-out work and the bill against a connection flood.

Its own subdomain, so the main CSP never bends. A browser can’t open a WebSocket unless the page’s CSP connect-src allows the wss:// endpoint. Rather than punch that hole in andruxa.dev’s strict CSP, the canvas lives on place.andruxa.dev — its own CloudFront distribution, its own dedicated ACM cert, and its own headers policy that is identical to the main site’s except for the one wss:// addition. The portfolio’s CSP stays pristine; the relaxation is quarantined to the one page that needs it. Giving it a dedicated cert also means the shared main/demos certificate is never re-issued.

Reaping dead connections — and a bug that taught me the edge case. Broadcasting to a client that has silently dropped throws GoneException; those get reaped from the registry. I extended reaping to BadRequestException (an invalid/stale connection id) too — and promptly broke connections, because the peer-count broadcast fired during a client’s own $connect posts to the still-connecting socket, that post fails, and the new client got reaped a millisecond after joining. The fix: exclude the connecting socket from its own connect broadcast. (An earlier bug in the same stack: an auto_deploy WebSocket stage that deployed before its routes existed, so every handshake 403’d with zero Lambda invocations until the stage depended on the routes.)

Cost breakdown

ComponentMonthly cost
WebSocket messages ($1/M) + connection-minutes~$0.10–0.20 on demo nights
Lambda broadcast fan-outwithin the always-free tier
DynamoDB (pixels + cooldowns, on-demand + TTL)~$0.10
S3 snapshot + dedicated CloudFront distributionpennies
Total≈ $0.20 idle, ~$1–2 if it goes mildly viral (the connection cap bounds the worst case)

What I’d do differently at scale

At real r/place scale the single broadcast Lambda becomes the bottleneck — fanning out to hundreds of thousands of sockets from one function doesn’t hold. I’d move fan-out off the write path: writes go to DynamoDB, a DynamoDB Stream drives the broadcast, and connections are sharded across workers so no single invocation posts to every socket. The canvas itself would move from row-items to a binary representation with server-side batching of pixel deltas, and the cooldown would graduate from per-IP to real identity to survive NAT and mobile networks. The shape here — persistent connections, atomic writes, bounded fan-out, abuse control at the edge — is the part that carries; only the fan-out mechanism has to change.