Design a collaborative pixel canvas like r/place
One giant shared image that millions of people edit one pixel at a time, with a cooldown between placements. The hard parts are storing and shipping millions of pixels compactly, loading a snapshot and the live changes without gaps, drawing it all on one canvas at 60 frames per second, zooming to a single pixel on a phone, placing a pixel with honest feedback, and fanning every change out to millions of open tabs.
The question
Design the web client for a collaborative pixel canvas like Reddit's r/place. There is one shared board of a few million pixels and a fixed palette of colours. Any signed-in user can place one pixel, then must wait a few minutes before placing the next. Everyone sees everyone else's pixels appear live. The event runs for a few days with millions of people, mostly on phones.
Walk me through how the board is stored and loaded, how you draw it, how zoom and pan work, how a placement flows through the system, how live updates reach millions of clients, and what happens when the event ends.
Real-life examples: Reddit r/place (2017, 2022, 2023), wplace, pxls.space, The Million Dollar Homepage (the static ancestor)
Requirements: what kind of canvas is this?
On April Fools' Day 2017, Reddit opened r/place: a blank square of 1,000 by 1,000 pixels and a palette of 16 colours. Any account could colour one pixel, then had to wait five minutes (at times up to 20) before the next one. Over about three days, more than a million people placed about 16 million pixels: flags, logos and game characters. Reddit ran it again in 2022 and 2023 with a bigger board and more colours, and fan-made clones such as pxls.space and wplace keep the idea alive.
It looks like a toy. It is not. The whole board is one shared value that millions of people read and write at the same time, and each write is tiny: one pixel, one colour. That shape is unlike the other canvas problems on this site, and it is why this problem is a favourite in senior interviews.
Several neighbouring guides already cover parts of the toolkit, and we link them by name instead of teaching them again. The figma and excalidraw guides cover drawing a scene of vector objects, hit testing, and collaboration on shapes. The google-maps guide covers tiled pan and zoom and tile pyramids. The photo-editor and image-cropper guides cover pixel buffers, zoom maths and transforms.
The live-leaderboard and live-comments guides cover real-time fan-out to many viewers, and the multiplayer-trivia guide covers a server-driven clock that every player must see the same way. This guide focuses on what is special about one giant shared raster (a grid of pixels) edited one pixel at a time.
Start with questions. Each answer changes the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| How big is the board? | 2,000 by 2,000 pixels, 4 million in total, possibly growing during the event | Memory, snapshot size, and whether the board must be split into chunks |
| How many colours? | A fixed palette of 32 colours | Each pixel is a 5-bit palette index, not a 32-bit colour |
| How many people? | About 10 million over 4 days; up to 1.5 million tabs open at the peak (our estimate) | Fan-out is the main server cost; the client must be cheap to feed |
| How often can one user place? | Once every 5 minutes, set by the server and changeable live | Placement traffic is low per user, high in total |
| How fast do pixels change? | Hundreds per second on average, a few thousand per second at peaks (our estimate) | The client must batch drawing; the server should batch sending |
| Which devices? | Phones first, then desktop; many on mid-range Android over 4G | Memory limits, touch gestures, battery, and slow networks |
| What happens at the end? | The board freezes and becomes a read-only archive with a time-lapse | We need a read-only mode and a full history of placements |
Functional requirements
- Show the whole shared board, live, so other people's pixels appear within about a second.
- Zoom from the whole board down to single pixels, and pan around, with mouse, trackpad, touch and keyboard.
- Pick a colour from the palette, aim at one pixel, and place it.
- Show the cooldown: how long until you can place again, using the time the server sets.
- Click any pixel to see who placed it last and when.
- When the event ends, freeze the board, keep it viewable, and offer a time-lapse of how it grew.
Non-functional requirements
- First view of the full board in under 3 seconds on a mid-range phone over 4G. The snapshot is the biggest download, so it must be small and come from a CDN.
- Smooth zoom and pan at 60 frames per second, even while thousands of pixels change per second.
- No gaps: after loading, the client must show the same board the server has, give or take the last second.
- Bounded cost per client: one WebSocket, a few KB per second of updates at most, and memory in the tens of MB, not hundreds.
- Fairness: the server, not the client, decides cooldowns, valid coordinates and colours; bots must be expensive.
- Accessible: keyboard placement, announced coordinates and colours, colour names, and reduced motion.
Out of scope
- Drawing tools such as lines, fills or brushes. One pixel per action is the whole point of the product.
- Layers, undo, or private drafts. The board is one shared layer with no history editing.
- Chat and community coordination. Groups plan their art elsewhere; we only link to it.
- The server's internal storage engine in detail. We cover what the client needs from it and why.
Check your understanding · Exercise 1
Which answers to scoping questions would change this design the most? Select all that apply.
What makes a shared pixel board hard
Before choosing anything, name the forces that will push on every decision. A pixel board has five of them, and each later chapter answers one.
- It is huge but simple. 4 million cells, each holding one small number. That is too many for DOM nodes or objects, but small enough to fit in one typed array. The data format is the design.
- It is one shared value. Everyone reads and writes the same board. There are no private documents, no pages to paginate, and no way to show a user only their own data. Every client wants the whole thing.
- Writes are tiny and constant. A change is one pixel: an x, a y and a colour index, about 5 bytes of real information. But there are thousands per second, and every one must reach every viewer.
- Readers are millions, writers are throttled. The cooldown limits each user to one write every few minutes, so writes per user are rare, but the read fan-out (sending each write to every open tab) is enormous.
- It is mostly viewed on phones. A phone is about 390 CSS pixels wide, so a 2,000 by 2,000 board is about 5 screens wide and more than 2 tall at one board pixel per CSS pixel. People zoom in to place and zoom out to admire, with fingers, on mid-range devices.
The numbers that shape the design
Do the back-of-the-envelope maths before drawing boxes (the estimation-for-frontend lesson shows the habit). These are estimates for our assumed event, and they are the numbers we will keep coming back to.
| Quantity | Estimate | How we got it |
|---|---|---|
| Pixels on the board | 4,000,000 | 2,000 times 2,000 |
| Board as 32-bit RGBA | 16 MB | 4 bytes per pixel; what the GPU and ImageData want |
| Board as 1 byte per pixel | 4 MB | a palette index in a Uint8Array |
| Board packed at 5 bits per pixel | 2.5 MB | 32 colours need 5 bits |
| Board as a PNG snapshot | roughly 0.5 to 2 MB | indexed PNG compresses large flat areas well; noisy art compresses worse |
| Pixel changes per second, peak | about 2,000 | our assumption; check it against the event's real peak |
| Naive update bytes per client | about 100 KB per second | 2,000 changes times about 50 bytes of JSON each |
| Naive total fan-out at peak | about 150 GB per second | 100 KB per second times 1.5 million open tabs |
The last row is the one that wakes people up. Sending every change as its own JSON message to every tab costs about 1.2 terabits per second of egress. Most of this guide is about making that number small: a compact pixel format, batching changes into ticks, and only sending clients the parts of the board they can see.
The wireframe shows the screen we are building. The board fills the viewport, and a reticle (a square outline) marks the pixel you are aiming at, with its coordinates in a pill at the top. The palette sits at the bottom with a Place button and the cooldown timer, and tapping a pixel opens a small card saying who placed it. Everything except the board is ordinary DOM; the board is a single canvas.
Think first
Why not render each pixel as a small div, or each pixel as an SVG rect? Answer before reading on.
4 million elements would need hundreds of bytes each just for the DOM nodes and their style data, so over a gigabyte of memory, and every style or layout pass would walk millions of nodes. Even a 1,000 by 1,000 board is 1 million nodes, far past what any browser handles smoothly.
A canvas holds the same picture as one bitmap: 4 bytes per pixel, 16 MB for the whole board, drawn by the GPU as a single texture. The trade is that a canvas has no built-in structure, so hit testing, focus and accessibility become our job. Later chapters handle each of them.
Check your understanding · Exercise 2
A teammate says: the cooldown already limits each user to one pixel every five minutes, so real-time traffic will be small. What is wrong with that? Select all that apply.
The board model: one small number per pixel
Every other decision depends on how one pixel is stored, so look at how the real events sized the board. In 2017 Reddit's board was 1,000 by 1,000 with 16 colours. Its engineering post says the server kept it in Redis (an in-memory data store) as a bitfield: one long run of bits where every pixel took 4 bits, at offset x + 1000 * y. A million 4-bit values is 500 KB, and reading the whole board took under 100 ms.
In 2022 the board started at 1,000 by 1,000 with 16 colours and grew twice: to 2,000 by 1,000 with 24 colours on the second day, then to 2,000 by 2,000 with 32 colours on the third. In 2023 it reportedly opened with only 8 colours and grew six times, ending at 3,000 by 2,000 with 32 colours, which is not even square.
So a production board is not one fixed square. It is a grid that grows, with a palette that grows. The model must survive both changes without a page reload.
Decision How should the client and server represent one pixel?
What we pick: Use palette indexes everywhere and pick the packing per layer. On the server, packed bits (the Redis bitfield idea) keep the authoritative board small. On the wire, ship the snapshot as an indexed PNG or compressed bytes. In the client, unpack to one byte per pixel in a Uint8Array for simple, fast reads, plus a Uint32Array of RGBA colours for drawing. The palette is data from the server, so adding colours mid-event is a config change, not a code change.
Two arrays in the client: indexes and colours
The client keeps two typed arrays over the same board. The index array is a Uint8Array of width times height: the truth the client believes, used for hit testing, the colour under the reticle, and accessibility. The colour array is a Uint32Array view over the bytes of an ImageData: each slot is one whole RGBA pixel, so painting a pixel is one write instead of four.
Reddit's 2017 client used exactly this trick, and noted one catch: on little-endian machines (nearly all phones and PCs), the four bytes of a 32-bit number are stored lowest first, so the palette must be stored as ABGR numbers for the bytes to land in RGBA order.
type Board = {
width: number;
height: number;
index: Uint8Array; // palette index per pixel
image: ImageData; // RGBA bytes the canvas draws
rgba: Uint32Array; // same bytes, one slot per pixel
palette: Uint32Array; // palette index -> ABGR number
};
function toAbgr(hex: string): number {
const n = parseInt(hex.slice(1), 16); // "#RRGGBB"
const r = n >> 16, g = (n >> 8) & 255, b = n & 255;
return ((255 << 24) | (b << 16) | (g << 8) | r) >>> 0;
}
function setPixel(board: Board, x: number, y: number, colour: number) {
const i = y * board.width + x;
board.index[i] = colour;
board.rgba[i] = board.palette[colour];
}
For a 2,000 by 2,000 board this is 4 MB of indexes plus 16 MB of RGBA, about 20 MB. The canvas itself holds another 16 MB bitmap, often in GPU memory. That is the budget to defend on a phone: around 36 MB for the board, before any zoom tricks. Do not keep extra copies, such as an array of pixel objects, or a second ImageData per update.
Chunks: when the board grows
When the board grows from 1,000 by 1,000 to 2,000 by 2,000, an offset of x + 1000 * y no longer means the same pixel. There are two ways out: reallocate a bigger array and copy rows across (cheap: a 4 MB copy takes a few milliseconds), or split the board into fixed chunks.
A chunk (also called a canvas or quadrant) is a fixed 1,000 by 1,000 tile with its own snapshot, its own update stream and its own address. Growth then means adding chunks, and old chunks keep their URLs and caches. Reddit's 2022 board was served this way, as separate 1,000 by 1,000 canvases placed side by side (the loading chapter shows the protocol). Chunks also prepare for the region subscriptions we use later, so we pick them.
Check your understanding · Exercise 3
The event adds 16 colours mid-way, going from 16 to 32. With the design above, what has to change? Select all that apply.
Loading the board: a snapshot plus the changes since
A new visitor needs the whole board, then every change after it. The board changes thousands of times per second, so there is no quiet moment to take a clean copy. The loading protocol must stitch a snapshot taken at one instant to a live stream that never stops, with no missing pixels and no pixels applied twice in the wrong order.
The naive version: fetch the board, then open the WebSocket. Between the snapshot being generated and the socket being subscribed, maybe 1 to 3 seconds pass. At 2,000 changes per second, that is up to 6,000 pixels the client never hears about. They stay wrong until someone paints over them, which on a quiet corner may be never, and two friends see different art in the same spot.
Decision How does a client join the stream without gaps?
What we pick: Use sequence numbers. The snapshot comes from the CDN, where it is cheap and shared; the socket only carries small deltas; and the sequence number turns the hand-off into a rule the client can check. Reddit's 2017 approach was close to this and worked, but its own post admits the snapshot could be a few seconds stale, which is exactly the case the sequence check makes safe.
The snapshot: a cached image per chunk
Each chunk's snapshot is regenerated every few seconds and published at an immutable URL that contains its sequence number, for example /chunks/0/snap-1048200.png, with Cache-Control: public, max-age=31536000, immutable. A tiny pointer, /chunks/0/latest.json, says which snapshot is current and is cached for 1 to 2 seconds with stale-while-revalidate (the cdn-caching lesson explains both headers).
Reddit used the same trick in 2017: the full board was cached at the CDN for 1 second, so its own servers saw at most about 33 board requests per second, one per CDN location, no matter how many people loaded the page.
Why a PNG? An indexed PNG of a 1,000 by 1,000 chunk is often a few hundred KB, the browser decodes it natively and off the main thread with createImageBitmap, and anyone can open the URL to see the board. Reddit's 2022 clients also received the board as PNG images.
The catch: decoding gives RGBA, not palette indexes. The client draws the bitmap right away for a fast first paint, then a worker reads the pixels back and maps each RGBA value to its palette index with a lookup table, about 1 million lookups per chunk. Pass colorSpaceConversion: "none" to createImageBitmap so a colour profile cannot shift the values the lookup expects.
The alternative, raw packed bytes compressed with Brotli, gives indexes directly but cannot be drawn until it is unpacked. Reddit's 2017 board endpoint sent packed bytes like this, two 4-bit pixels per byte. Both work; the PNG wins on debuggability and first paint.
The replay rule in code
type Update = { seq: number; x: number; y: number; c: number };
async function joinChunk(chunk: Chunk, socket: Socket) {
const buffer: Update[] = [];
let live = false;
socket.subscribe(chunk.id, (batch: Update[]) => {
if (live) chunk.apply(batch); else buffer.push(...batch);
});
const { seq, url } = await fetchJson(chunk.latestUrl);
await chunk.drawSnapshot(url); // sets chunk.seq = seq
const first = buffer[0]?.seq ?? seq + 1;
if (first > seq + 1) await chunk.fillGap(seq + 1, first - 1);
chunk.apply(buffer.filter((u) => u.seq > seq));
live = true;
}
Two details matter. The subscribe call happens before the snapshot fetch, so the buffer covers the window. And fillGap exists for the case where the CDN handed back a snapshot older than the moment we subscribed: it asks a small HTTP endpoint for the missing range, or, if the range is big, fetches the next snapshot and runs the rule again. Try both cases in the lab below.
What Reddit did in 2022
Open-source clients written against the 2022 event show the shape of Reddit's protocol. The client opened one GraphQL subscription over WebSocket for configuration (the palette, the canvas size, and a list of canvases each with an x and y offset), then one subscription per 1,000 by 1,000 canvas.
Each canvas subscription first delivered a full frame: a message carrying the URL of a PNG of that whole canvas. After that it delivered diff frames: URLs of small images holding only the pixels changed since the previous frame, each tagged with its own timestamp and the previous frame's timestamp. That previousTimestamp link plays the role of our sequence number: a client that sees a diff whose previous timestamp is not the last one it applied knows it missed a frame.
Check your understanding · Exercise 4
The snapshot says it includes everything up to sequence 500. The buffer holds updates 497 to 512. What should the client do? Select all that apply.
Rendering: one bitmap, scaled by the GPU
We have a board in typed arrays. Now it must reach the screen, at any zoom from the whole board to one pixel the size of a thumb, while changes stream in. The think block in chapter 2 already ruled out DOM nodes. The real choice is between three canvas strategies.
Decision How do we draw a 4-million-pixel board at any zoom?
What we pick: Start with board-sized 2D canvases per chunk, scaled by one CSS transform with image-rendering: pixelated. It is the least code and moves pan and zoom to the compositor. Switch to smooth scaling when zoomed out below 1x. Keep WebGL as the upgrade path if the board outgrows device canvas limits or you want shader effects; mention it with its numbers in the interview, but do not lead with it.
transform, which the compositor applies to the bitmap it already has.First paint and the full-board write
On load, each chunk's PNG is decoded with createImageBitmap and drawn with one drawImage call: the user sees the board as soon as the bytes arrive. After that, the client switches to its own ImageData as the source of truth for drawing. A full putImageData of a 1,000 by 1,000 chunk copies 4 MB and typically takes a few milliseconds on desktop and longer on low-end phones, so we do it once per chunk, never per update.
Updates: write arrays now, draw one dirty rectangle per frame
Reddit's 2017 client first drew every incoming pixel with fillRect as it arrived, and bursts of updates froze the browser. The fix it describes is the pattern we use: write each update into the typed arrays immediately (cheap), mark the chunk dirty, and draw in a requestAnimationFrame callback. Everything that arrives in one frame, about 16 ms at 60 Hz, becomes one draw.
We go one step further and track the dirty rectangle: the smallest box covering all changed pixels in that frame. putImageData accepts a source rectangle, so we copy only that box.
let dirty: { x0: number; y0: number; x1: number; y1: number } | null = null;
let scheduled = false;
function onUpdate(u: Update) {
setPixel(board, u.x, u.y, u.c); // arrays only, no drawing
dirty = dirty
? { x0: Math.min(dirty.x0, u.x), y0: Math.min(dirty.y0, u.y),
x1: Math.max(dirty.x1, u.x), y1: Math.max(dirty.y1, u.y) }
: { x0: u.x, y0: u.y, x1: u.x, y1: u.y };
if (!scheduled) { scheduled = true; requestAnimationFrame(flush); }
}
function flush() {
scheduled = false;
if (!dirty) return;
const { x0, y0, x1, y1 } = dirty;
ctx.putImageData(board.image, 0, 0, x0, y0, x1 - x0 + 1, y1 - y0 + 1);
dirty = null;
}
One box is not always small. Two pixels at opposite corners make a box covering the whole chunk. A practical refinement is a coarse grid of dirty tiles, say 64 by 64 pixels each, with one putImageData per dirty tile; at 2,000 updates per second spread over 4 chunks, a frame touches about 8 pixels per chunk, so a handful of small copies. The runtime-performance lesson covers how to measure this with the Performance panel; the reflow-repaint-composite lesson explains why the CSS transform skips layout and paint.
Moving drawing off the main thread
canvas.transferControlToOffscreen() hands a canvas to a Web Worker as an OffscreenCanvas, supported in Chrome since 69, Firefox since 105 and Safari since 16.4. The worker can then own the socket messages, the typed arrays, the PNG decoding and the drawing, while the main thread only handles input, the camera transform and the DOM controls.
That keeps tap-to-place and the palette responsive (good INP, Interaction to Next Paint) even during an update storm. The cost is a message hop for every question the UI asks, such as which colour is under the reticle, so keep a small mirror of what the UI needs, or ask the worker asynchronously.
Check your understanding · Exercise 5
Panning feels janky on a mid-range phone with the board-sized canvas design. Which changes help? Select all that apply.
Zoom, pan, and aiming at one pixel
The general maths of a camera (a scale and an offset, zooming around the cursor, pinch with two pointers, wheel and trackpad deltas, clamping) is covered in the google-maps and image-cropper guides, so we only summarise it. What is special here is the target: one cell out of 4 million, chosen with a finger, where a mistake costs five minutes.
The camera is three numbers: scale (screen pixels per board pixel) and an offset tx, ty. A screen point maps to a board pixel with one division and a floor. The floor is the important part: board pixels are integers, and a point anywhere inside a cell must land on that cell, not round to its neighbour.
type Camera = { scale: number; tx: number; ty: number };
function toBoard(cam: Camera, sx: number, sy: number, w: number, h: number) {
const x = Math.floor((sx - cam.tx) / cam.scale);
const y = Math.floor((sy - cam.ty) / cam.scale);
if (x < 0 || y < 0 || x >= w || y >= h) return null; // off the board
return { x, y };
}
function cellOnScreen(cam: Camera, x: number, y: number) {
return { left: x * cam.scale + cam.tx, top: y * cam.scale + cam.ty, size: cam.scale };
}
x to x + 1 in board units, so Math.floor is right and Math.round sends taps in the right half of a cell to its neighbour.The reticle: aim first, place second
Never place a pixel on the same tap that selects it. A tap moves the reticle, a square outline drawn exactly over one cell using cellOnScreen, and the coordinates show in a pill such as (1203, 487). Placing is a separate button press.
On phones, a common pattern keeps the reticle fixed at the centre of the screen and pans the board under it, so the thumb never hides the target. Reddit's 2017 post describes another aid: it zoomed to 40 times for placing, so each tile was a 40 by 40 target, and 4 times for viewing. A 40 CSS pixel target is comfortably above WCAG 2.2's 24 by 24 minimum target size.
Gestures that do not fight each other
- Set
touch-action: noneon the board so the browser does not scroll or zoom the page, and handle pointer events yourself; callsetPointerCaptureso a drag that leaves the board keeps working. - A tap is a pointer down and up that moved less than about 6 CSS pixels; anything more is a pan. Without that threshold, every pan ends by moving the reticle.
- Two active pointers mean pinch: zoom around their midpoint. In Chrome, Edge and Firefox, a trackpad pinch arrives as a
wheelevent withctrlKeyset; treat it as zoom, not scroll. Desktop Safari also sends its own non-standardgesturechangeevents. - Clamp zoom between fit-the-board and about 50 screen pixels per board pixel, and clamp pan so at least part of the board stays on screen.
- Round
txandtyto whole device pixels when the camera stops moving, so cell edges line up with screen pixels and grid lines stay sharp.
Check your understanding · Exercise 6
Users report that their pixel sometimes lands one cell away from where they aimed. Which bugs could cause it? Select all that apply.
Live updates: ordering, delivery to millions, and catching up
Chapter 5 made drawing cheap: write the arrays now, copy a dirty rectangle once per frame. This chapter is first about correctness, then about cost. Two people can paint the same pixel a few milliseconds apart, and messages can arrive late, twice, or in batches from different servers. Every client must end with the same colour on that pixel, and it must be the colour the server stored.
The rule is simple because the board is simple: the server's order is the truth, and the last write in that order wins. There is no merging. A pixel holds one value, so we do not need the CRDTs or operational transforms that the figma and google-docs guides need for richer data (the conflict-resolution lesson compares them). The server assigns each accepted placement a sequence number per chunk, and the client keeps, per chunk, the highest sequence it has applied.
- If an update's sequence is at or below the applied sequence, it is a duplicate or stale: drop it.
- If it is exactly one above, apply it and move the counter.
- If it jumps ahead, there is a gap. Hold the early updates for a short time (say 500 ms) in case the missing ones arrive, then ask the server for the missing range, or reload the chunk snapshot if the gap is large.
- Updates arrive from the server already grouped into batches (see the delivery section below), so in practice the check runs once per batch: the batch carries its first and last sequence numbers.
Why not use timestamps? Clocks on different servers drift by milliseconds, and two placements in the same millisecond tie. A timestamp tells you roughly when, while a sequence tells you exactly what comes next and whether something is missing. Reddit's 2022 diff frames carried both a current and a previous timestamp, which gives the same gap check as long as one writer produces all frames for a canvas.
The lab below runs a small board with simulated painters. Turn on per-frame batching and watch how many draw calls it saves; look at the dirty rectangle each frame; place your own pixel and watch the cooldown. It is a model of the real client at a scale you can see.
Check your understanding · Exercise 7
A client has applied chunk 2 up to sequence 9,000. It receives a batch covering 9,004 to 9,050. What should it do? Select all that apply.
Delivery to millions: ticks, coalescing and regions
Chapter 2 ended with a scary number: sending each change as its own JSON message to every tab would cost about 150 GB per second at our peak. The websockets and choosing-realtime lessons explain why a WebSocket is the right pipe for a stream like this (server push, low overhead per message, one connection). The live-leaderboard, live-comments and multiplayer-trivia guides cover generic fan-out tiers to many sockets. Here we shrink the bytes, using what is special about pixels.
For scale, Reddit's 2017 post reports its WebSocket service peaked at about 100,000 connections, roughly 80,000 of them for r/place, and over 4 Gbps of outgoing traffic. That is about 5 KB per second per connection, with a peak of almost 200 placements per second. Our assumed event has 10 times the placement rate and 15 times the viewers, so the same approach would need around 600 Gbps. We need to do better than linear.
Decision How do pixel changes reach millions of open tabs?
What we pick: Combine the middle two: binary ticks of about 200 ms, coalesced per pixel, sent per region, with each client subscribed only to the regions it can see and an overview stream when zoomed out. Keep the pointer-plus-CDN design as the answer to the follow-up how would you go to 10 times the audience: it moves the fan-out of bytes to infrastructure already built for it, which is the bet Reddit made in 2022.
Rough maths for the chosen design: 2,000 changes per second are 400 per 200 ms tick, maybe 300 after coalescing, at 4 bytes each, so 1.2 KB per tick or 6 KB per second for a client that watches the whole board.
A zoomed-in phone subscribed to 2 of 16 regions gets under 1 KB per second. If half of 1.5 million tabs are zoomed in, and we pessimistically count every other tab at the full 6 KB per second, total egress lands around 5 GB per second, about 30 times less than the naive design. Every number here is an estimate; the point in an interview is the method.
One detail changes when clients subscribe by region instead of by chunk. A client that hears only part of a chunk would see gaps in a per-chunk sequence that are only other regions' changes. So each region numbers its own changes, a chunk's latest.json lists the last sequence of each region it covers, and the replay and gap rules from the start of this chapter run per region.
Reconnect and catch-up
Phones lose connections constantly: a lift, a tunnel, switching from Wi-Fi to 4G. On reconnect the client tells the server, per region, the last sequence it applied. The server keeps a short history per region, for example the last 60 seconds in a ring buffer (a fixed-size list that overwrites its oldest entry).
If the client's sequence is still in the buffer, the server sends the missed changes and the stream resumes. If not, the server answers too far behind, and the client rejoins that region from a fresh chunk snapshot, exactly like a first load.
| Time offline | Missed changes (at 2,000 per second) | What the client does |
|---|---|---|
| Under 1 second | under 2,000 | Resume; the server replays from the ring buffer |
| 1 to 60 seconds | 2,000 to 120,000 | Resume from the ring buffer, applied in one frame; or a snapshot if that is smaller |
| Over 60 seconds | over 120,000 | Snapshot plus deltas per visible chunk, from the CDN |
| Server deploy, everyone at once | any | Backoff with random jitter so 1.5 million reconnects spread over a minute; snapshots come from the CDN, not the origin |
Reconnect with exponential backoff plus jitter (the retries-and-backoff lesson): 1, 2, 4, 8 seconds up to a cap, each multiplied by a random factor. Without jitter, a socket-server restart produces synchronised waves of reconnects that knock it over again. While disconnected, keep showing the board, mark it as paused with a small banner, and disable Place: a placement without a live stream cannot show an honest result.
Background tabs
Browsers pause requestAnimationFrame in hidden tabs, but socket messages still arrive. Writing them into the arrays is cheap, so a tab hidden for a minute catches up with one draw when it returns. A tab hidden for an hour is different: it is still downloading every update for a board nobody looks at. Listen to visibilitychange; after a few minutes hidden, unsubscribe, and on return, rejoin each chunk with the snapshot-plus-deltas rule from chapter 4, which saves the user's data plan and the server's fan-out.
Check your understanding · Exercise 8
Which changes reduce total fan-out bandwidth without hurting what users see? Select all that apply.
Placing a pixel: optimistic paint, server rules
Placing is the only write a user makes, and it is precious: one try every five minutes. It has to feel instant, it has to be honest when it fails, and it must be impossible to cheat from the browser. The server decides three things: the coordinates are on the board, the colour is in the current palette, and this account's cooldown has passed.
Decision What does the user see between tapping Place and the server's answer?
What we pick: Pending overlay. The optimistic-updates lesson shows the general pattern; here the key twist is that the shared board is never mutated locally. The overlay is a small map from pixel to colour, drawn as a DOM square or on a tiny overlay canvas with the reticle. Success and failure both end the same way: remove the overlay entry and trust the board.
The cooldown timer comes from the server
The cooldown is server state. The server stores when each account may next place, and the client only displays it. A common bug is to send an absolute time like nextAllowedAt: 1745000000000 and subtract Date.now(): if the phone's clock is two minutes fast, the timer reaches zero two minutes early and the user's next tap fails.
Send the remaining duration instead, cooldownMs: 300000, and count down from performance.now(), which is monotonic (it never jumps when the system clock changes). Recompute after the tab wakes up, and refresh the value from the server on reconnect. The multiplayer-trivia guide uses the same idea for a question deadline that every player must see the same way.
The cooldown should also be changeable during the event: Reddit's 2017 requirements list says board size and cooldown had to be adjustable on the fly, and Wikipedia reports that it varied between 5 and 20 minutes during the event. So every placement response and the config message carry the current cooldown, and the client never hardcodes five minutes.
Races, bots, and fairness
Reddit's 2017 post describes a real race: the server read the last placement time, wrote the tile, then wrote the new time, with no lock around the three steps. Simultaneous requests all passed the check, and about 15,000 tiles (around 0.09 percent) were placed through the gap.
The fix is an atomic check-and-set, for example one Redis SET with NX and an expiry equal to the cooldown: only one request can create the key. On the client, disable the button while a request is in flight and send an idempotency key so a retried request cannot place twice (the retries-and-backoff lesson covers both).
Bots are the harder problem, because a script that respects the cooldown looks like a person. Typical defences, all server side, are: require accounts older than some minimum, give unverified accounts a longer cooldown, add a challenge (a CAPTCHA) when behaviour looks automated, rate-limit per IP and per device, and watch for many accounts painting one template in lockstep. None is perfect, and in practice organised communities ran scripts anyway; say so in the interview. The client's job is small: never trust itself, show clear errors, and support the challenge flow.
Check your understanding · Exercise 9
A user places red at (40, 12). While the request is pending, the stream delivers blue at (40, 12) from someone else. Then the server accepts the red placement. With the pending overlay design, what happens? Select all that apply.
Architecture, data model and API
We now have every piece. The architecture puts them behind clear boundaries, and the main boundary is between the main thread, which owns input and DOM controls, and a board worker, which owns the bytes: the socket stream, the typed arrays, the snapshot decoding and the drawing. The board can take a burst of 10,000 updates without the palette or the Place button missing a tap.
| Part | Lives in | Owns | Why the boundary is here |
|---|---|---|---|
| Camera controller | Main thread | Pointer, wheel and keyboard input; scale and offset; the CSS transform; URL sync | Input must answer within one frame, and the transform is applied to DOM |
| Board worker | Web Worker with OffscreenCanvas | Per-chunk index and RGBA arrays, sequence counters, dirty tiles, drawing | The heavy, bursty work, kept off the input thread |
| Sync client | Board worker | Snapshot fetch, socket subscriptions per region, gap detection, catch-up, backoff | Close to the arrays it writes, so updates never cross a thread twice |
| Placement service | Main thread | Place requests, idempotency keys, the pending overlay, cooldown deadline | It is user-facing state that the UI renders directly |
| Overlay layer | Main thread DOM | Reticle, pending pixel marker, coordinates pill, who-placed card | A few elements positioned with the camera; easy to make accessible |
| UI shell | Main thread React | Palette, Place button, timer, banners, settings | Ordinary components; nothing here touches pixels |
The worker and the main thread talk through a small message protocol: the main thread sends the camera's visible rectangle (so the sync client can pick regions) and questions like what colour is at (x, y); the worker sends status (connected, paused, behind), and the sequence numbers that confirm a pending placement. The CSS transform stays on the main thread because the canvas element lives in the DOM even after its drawing moves to the worker.
Follow the data flow figure from left to right. Reads come from two places: the CDN serves snapshots and catch-up ranges over HTTP, and the socket tier pushes binary ticks. Both land in the worker's arrays, then become dirty tiles, then a putImageData, and the compositor shows the result through the camera transform.
Writes go the other way through a different door: a POST to the placement API, which validates, stores, and publishes to the same stream every client reads, including the placer's own tab. The placer learns the result twice, from the HTTP response (accepted, with a sequence and the next cooldown) and from the stream (the pixel itself), and the overlay waits for the second.
Check your understanding · Exercise 10
Why does the sync client live inside the board worker instead of on the main thread? Select all that apply.
Data model and API
The client model is small because the board is just bytes. The interesting part is what each piece is keyed by and where it lives: chunks by id in the worker, the camera in the URL, the placement and cooldown in UI state, and nothing in IndexedDB, because a board snapshot from yesterday is worth less than a fresh one from the CDN.
type Config = { // from GET /config and the socket
width: number; height: number; // may grow mid-event
chunkSize: number; // 1000
palette: { index: number; hex: string; name: string }[];
cooldownMs: number; status: "open" | "ending" | "frozen";
allowed?: number[]; // palette subset, e.g. white only at the end
};
type ChunkState = {
id: string; originX: number; originY: number;
seq: number; // highest applied
index: Uint8Array; rgba: Uint32Array; // in the worker
phase: "loading" | "live" | "behind";
};
type Placement = {
x: number; y: number; colour: number;
key: string; // idempotency key
state: "sending" | "accepted" | "rejected";
seq?: number; error?: "cooldown" | "palette" | "bounds" | "challenge" | "frozen";
};
The API has four HTTP endpoints and one socket. Snapshots and ranges are cacheable GETs; placement and the pixel lookup are not cached. Errors use one shape with a machine-readable code so the UI can map each to a clear message.
GET /config -> Config (cached 5 s)
GET /chunks/{id}/latest.json -> { url: "/chunks/0/snap-1048200.png",
seq: { r0: 1048190, r1: 1047951, r4: 1048107, r5: 1048200 } }
GET /regions/{id}/updates?from=1048201&to=1048420 -> binary updates, or 410 if too old
POST /pixels { x, y, colour, key } -> 200 { seq: 1048421, cooldownMs: 300000 }
-> 429 { code: "cooldown", cooldownMs: 112000 }
-> 403 { code: "challenge", challengeUrl: "..." }
GET /pixels/{x}/{y} -> { user: "u/pixelfan", at: "2026-04-02T10:14:03Z" }
socket -> { type: "subscribe", regions: ["r5", "r6"], from: { r5: 1048200 } }
socket <- binary frame: region, firstSeq, lastSeq, then N packed 4-byte changes
socket <- { type: "config", ... } // palette, size, cooldown, status changes
socket <- { type: "behind", region: "r5" } // resume impossible: reload snapshot
Note the 429 response carries the remaining cooldown, so a rejected placement immediately corrects the client's timer. And note that config changes travel on the socket: when the board grows, the palette gains colours, or the event enters its ending phase, every client hears it within a second without polling.
Who placed this, the time-lapse, and the ending
Three features sit around the board: looking up a pixel's author, replaying history, and ending the event. They share one source, an append-only log of every accepted placement: sequence, x, y, colour, user id, time. Reddit's 2017 post describes the same split: the board in Redis for speed, and every tile's details in Cassandra so users could inspect who placed it.
Who placed this pixel?
Do not ship authors with the board: user names would multiply the snapshot by 10 or more and turn every update into a privacy leak. Look them up on demand with GET /pixels/x/y when the user taps a pixel and holds the reticle on it, and cancel the request with an AbortController if they move on (the request-races lesson).
Reddit's 2017 lookup endpoint was not CDN cached, and its post notes people scraped the whole board through it one tile at a time; so rate-limit it, and consider caching each answer for a second or two. Privacy matters too: show the public user name only, never internal ids, and respect deleted or suspended accounts.
The time-lapse
The log makes history free. A time-lapse player loads periodic keyframes (full snapshots, say every 10 minutes) plus the compressed log between them, and replays placements into the same typed arrays at any speed. Seeking means jumping to the nearest earlier keyframe and replaying forward, the same idea as video keyframes.
At 160 million placements, which is what press coverage and Wikipedia report for 2022, even a compact log at 8 bytes per placement (position, colour and a time offset, without the user) is about 1.3 GB, so the public time-lapse is usually pre-rendered into a video, and the interactive player streams only the hour being viewed.
Moderation and the ending
Moderators need to remove hateful art fast. The simplest tool is a privileged placement that paints a rectangle in one go, published through the same stream with ordinary sequence numbers, so clients need no special path.
The ending is a sequence of config changes: in the final hours of 2022, Reddit allowed only white, and the community slowly whited out the board; in 2023 the palette reportedly dropped to greyscale and then to white only. Then the board froze. The client handles all of this through Config: allowed greys out palette entries, status: "frozen" hides the Place button, stops the cooldown timer, closes the socket, and switches the page to the final snapshot served with a long cache lifetime.
Think first
The board freezes at exactly 12:00. A user tapped Place at 11:59:59.9 and the request reaches the server at 12:00:00.2. Who decides, and what does the user see?
The server decides, by the time it processes the request, not the time the user tapped. It answers 403 with code frozen, and the client removes the pending overlay and shows that the canvas is closed.
To be fair at the edge, the server can announce the end a few minutes early through the config message, show a countdown, and disable placing for everyone at the same server moment. Client clocks never decide.
Accessibility, phones, and bad networks
A canvas is a picture to assistive technology: a screen reader sees nothing in it, and there is no focusable element for each pixel. We have to build the accessible version ourselves, around the reticle.
- Make the board a focusable element (
tabindex="0",role="application"with a clear label) so arrow keys move the reticle one pixel, Shift plus arrows move 10, and plus or minus zoom. The keyboard-navigation lesson covers roving focus; here the cursor is a coordinate, not an element. - After each move, update a polite live region with the coordinate and colour name, for example "x 1203, y 487, dark blue, placed by u/pixelfan"; debounce it so holding an arrow key does not flood the reader. Never announce other people's placements: thousands per second would drown everything else.
- Give every palette entry a name ("orange", "light grey") and render the palette as a radio group, so choosing a colour is announced and works with arrow keys.
- Do not rely on colour alone: the selected swatch gets a thick outline and a check mark, the pending pixel gets a dashed outline, and the reticle uses a two-tone border that shows on any colour (the visual-accessibility lesson).
- Honour
prefers-reduced-motion: no animated zoom flights, no pulsing pending marker; jump and show a static outline instead. - Place and the cooldown timer are ordinary buttons and text. Announce "You can place again" once when the timer ends, not every second.
Phones: memory and device pixels
MDN notes that iOS limits a canvas to 4,096 by 4,096 pixels, and that a canvas past the limit becomes unusable: drawing commands stop working, with no error. Published test results describe the iOS limit as an area of 16,777,216 pixels. Per-chunk canvases of 1,000 by 1,000 stay far below it, and so would a single 3,000 by 2,000 canvas.
Memory is the tighter limit: four chunks cost about 16 MB of canvas bitmaps plus 20 MB of arrays. Safari also caps the total memory of all canvases on a page, so never create canvases per update or keep old ones alive. Do not multiply the board canvas by devicePixelRatio: it is scaled by the camera anyway, and a 3x phone would need 9 times the memory for no visible gain. Only the screen-sized overlay needs device pixels for crisp lines.
Slow and offline
On a slow 3G link, a 2 MB board takes many seconds. Load the chunk under the camera first, show a small downscaled overview of the whole board (a few KB) right away, and fill the rest in.
When offline, keep the last board visible with a clear paused banner and the time of the last update, disable Place, and do not queue placements: a placement queued for later would land on a board that has changed and against a cooldown the server has not counted. When the connection returns, follow the catch-up table from chapter 7.
Check your understanding · Exercise 11
Which make the board usable with a keyboard and screen reader? Select all that apply.
Running it in the interview
In 60 minutes you cannot cover all of this. The signal interviewers look for is that you find the two or three decisions that matter for a giant shared raster, do the numbers, and handle the edges of each. A plan that works:
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Scope: board size, palette, users, cooldown, devices, ending | The pixel format drives memory, snapshot size and message size, so I will fix it first |
| 5 to 10 | Numbers: 4 MB of indexes, 16 MB RGBA, changes per second, naive fan-out | Fan-out, not writes, is the cost; the cooldown does not save us |
| 10 to 20 | Board model and loading: palette indexes, chunks, snapshot from the CDN plus sequenced deltas | Subscribe first, buffer, replay only what is newer than the snapshot |
| 20 to 30 | Rendering and camera: canvas per chunk, CSS transform, pixelated, dirty tiles per frame, worker | Pan never redraws; updates cost one small copy per frame |
| 30 to 40 | Placement: pending overlay, server cooldown as a duration, atomic check, bots | The board arrays only ever hold server truth |
| 40 to 50 | Fan-out: ticks, coalescing, binary, region subscriptions, reconnect | Draw the architecture and data flow here |
| 50 to 60 | Ending, history, accessibility, phones, follow-ups | Pick what the interviewer leans toward |
Draw the wireframe first (it fixes vocabulary: reticle, palette, timer), then the loading sequence, then the architecture. Expect these follow-ups, and have a one-paragraph answer ready for each.
- The board grows mid-event. Chunks: new chunks get new subscriptions and snapshots; the config message tells clients the new size; old chunks keep their caches.
- 10 times the users. Move bytes to the CDN: the socket carries pointers to diff files, as Reddit did in 2022; shrink regions; lengthen ticks for zoomed-out clients.
- Someone writes a bot. It is all server side: account age, verified email, challenges, per-IP limits, template detection. The client only reports and complies.
- The socket server restarts. Jittered backoff, resume from per-region sequences, snapshot from the CDN when too far behind.
- Why not WebGL? It cuts GPU memory for the board to a quarter and makes effects easy, but needs a fallback and context-loss handling. Start with 2D; switch when limits bite.
- How would you test it? A simulated stream with seeded random painters (like the lab), assertions that every client's arrays match the server log after random disconnects, and a frame-time budget in CI with the Performance API.
Check your understanding · Exercise 12
The interviewer asks what you would build first if you had one week. Which answers show good judgement? Select all that apply.
Summary
- Store each pixel as a palette index, packed on the server, one byte in the client, mapped to RGBA only for drawing. That one choice sizes memory, snapshots and messages.
- Load with subscribe first, then a CDN-cached snapshot that carries a sequence number; replay only buffered changes newer than it, and treat any jump in sequence as a gap to fill.
- Draw each chunk once into a board-sized canvas and let a CSS transform with image-rendering: pixelated do pan and zoom; batch updates per animation frame and copy only dirty tiles.
- Place with a pending overlay above the board, never by mutating the shared arrays; show the cooldown from a server-sent duration counted on a monotonic clock.
- Fan-out is the cost: coalesce per pixel, encode in binary, send in ticks, subscribe only to visible regions, and move bytes to the CDN when the audience grows.
- Reconnect with jittered backoff, resume from the last sequence while the server's short history covers it, and fall back to a fresh snapshot when it does not.
- A canvas is invisible to assistive technology: build a keyboard reticle, announce coordinates and colour names, and never rely on colour alone.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- How We Built r/Place (Reddit, 2017) Redis bitfield, CDN-cached board, buffered replay, Uint32Array drawing, requestAnimationFrame batching, and the cooldown race.
- r/place (Wikipedia) Board sizes, palettes and endings of the 2017, 2022 and 2023 events.
- Pixel manipulation with canvas (MDN) ImageData, putImageData with a dirty rectangle, and smoothing.
- The canvas element, including maximum sizes (MDN) Canvas size limits, including 4,096 by 4,096 on iOS.
- canvas-size: test results Measured maximum canvas width, height and area per browser, including 16,777,216 pixels on iOS Safari.
- OffscreenCanvas (MDN) Rendering in a worker with transferControlToOffscreen.
- image-rendering (MDN) Keeping pixel art crisp when scaled.
- Redis BITFIELD command The packed storage behind the 2017 board.
- Understanding Success Criterion 2.5.8: Target Size (Minimum) The 24 by 24 CSS pixel minimum target.
Concepts behind it
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Choosing a real-time techniqueHow to pick between short polling, long polling, server-sent events, WebSockets, and WebRTC for each feature, starting from who talks, how often, and how fresh the data must be, then checking scale, proxies, battery, and the fallback you will ship.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.
- Back-of-the-envelope numbers for frontendQuick, rough math with round numbers (bytes over the network, CPU time on a cheap phone, memory in a long-lived tab, and requests per second at the server) that turns "this might be slow" into a reason to pick one design over another.