cf.completefrontend
My progress
SYSTEM DESIGN · #119

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.

QuestionAnswer we assumeWhat it changes
How big is the board?2,000 by 2,000 pixels, 4 million in total, possibly growing during the eventMemory, snapshot size, and whether the board must be split into chunks
How many colours?A fixed palette of 32 coloursEach 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 livePlacement 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 4GMemory limits, touch gestures, battery, and slow networks
What happens at the end?The board freezes and becomes a read-only archive with a time-lapseWe 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

QuantityEstimateHow we got it
Pixels on the board4,000,0002,000 times 2,000
Board as 32-bit RGBA16 MB4 bytes per pixel; what the GPU and ImageData want
Board as 1 byte per pixel4 MBa palette index in a Uint8Array
Board packed at 5 bits per pixel2.5 MB32 colours need 5 bits
Board as a PNG snapshotroughly 0.5 to 2 MBindexed PNG compresses large flat areas well; noisy art compresses worse
Pixel changes per second, peakabout 2,000our assumption; check it against the event's real peak
Naive update bytes per clientabout 100 KB per second2,000 changes times about 50 bytes of JSON each
Naive total fan-out at peakabout 150 GB per second100 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.

Wireframe of the pixel canvas screen. A coordinates pill at the top reads 1203, 487. The board canvas fills the middle, a single bitmap, with a square reticle outlining one pixel and a small card next to it saying u/pixelfan placed it 2 minutes ago. At the bottom are the palette of colour swatches with one selected, a Place button, and a cooldown timer reading 4:12.r/place clone(1203, 487)board canvas: one bitmapreticleu/pixelfan2 min agoPlace4:12palette: named colourscooldown from the server
Only the board is a canvas. The reticle, coordinates, who-placed card, palette, button and timer are ordinary DOM on top of it, which keeps them accessible and easy to style.

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.

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?

A colour per pixel (RGBA, 4 bytes)

Store the final colour of every pixel, the way the screen and ImageData want it.

Updates send a hex colour like #FF4500.

Pros

  • No translation before drawing.
  • Any colour is possible, so the palette can change freely.

Cons

  • 16 MB for a 2,000 by 2,000 board, on the server, in the snapshot, and in every client.
  • Updates carry 4 bytes of colour where 5 bits would do.
  • The server must validate colours against the palette anyway.

A palette index per pixel (packed bits)

Store a small number per pixel that points into a palette array: 4 bits for 16 colours, 5 bits for 32.

The client maps index to colour at draw time.

Pros

  • The smallest possible state: 2.5 MB for 4 million pixels at 5 bits.
  • Validation is trivial: the index must be below the palette length.
  • Updates are tiny, a few bytes each in binary.

Cons

  • Bit packing across byte edges is awkward in JavaScript.
  • Changing from 4 to 5 bits mid-event means a new format.

A palette index per pixel (1 byte)

One Uint8Array slot per pixel holding the index, up to 256 colours.

Packing, if any, only happens on the wire, in a compressed snapshot.

Pros

  • Simple, fast indexing: board[y * width + x].
  • Palette can grow to 256 colours with no format change.
  • 4 MB for the whole board in memory, fine on phones.

Cons

  • Twice the memory of 4-bit packing.
  • Needs a palette lookup before drawing, one array read per 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.

How one pixel is stored. A row of palette indexes, 3, 3, 7, 0, 12, goes through a palette lookup that turns 3 into #FF4500 and 7 into #FFD635. Below, bars compare the size of a 2,000 by 2,000 board: 16 MB as RGBA at 4 bytes per pixel, 4 MB as one-byte indexes, 2.5 MB packed at 5 bits, and roughly 0.5 to 2 MB as an indexed PNG, which is an estimate.index array (Uint8Array)337012palette[i]3: #FF45007: #FFD635RGBA, 4 bytes16 MB1-byte index4 MB5-bit packed2.5 MBindexed PNGabout 0.5 to 2 MB (estimate)a 2,000 by 2,000 board: 4 million pixels
The board stores small indexes and turns them into colours only at draw time. The same choice shrinks memory, the snapshot download and every live update.

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.

ts
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];
}
The client board: index truth plus a colour view ready for putImageData.

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?

Subscribe first, buffer, replay everything

Open the socket and buffer every update.

Fetch the snapshot. When it lands, draw it, then apply every buffered update in order.

This is what Reddit's 2017 client did.

Pros

  • No gap: the buffer starts before the snapshot was taken, as long as the snapshot is no older than the buffer.
  • No sequence numbers needed.

Cons

  • If the snapshot is newer than the last buffered update (the socket can lag behind the snapshot), replaying paints older colours over newer ones until the socket catches up: a visible flicker.
  • If the CDN serves a snapshot older than the buffer's start, the gap comes back silently.

Sequence numbers: replay only what is newer

The server stamps each change with a sequence number per chunk, and the snapshot with the last sequence it includes.

Buffer from the socket, then apply only buffered updates with a sequence above the snapshot's; discard the rest.

If the buffer starts after the snapshot's sequence plus one, there is a gap: fetch the missing range or a newer snapshot.

Pros

  • Exact: no gap, no rewind, and gaps are detected instead of hidden.
  • The same rule handles reconnects later.

Cons

  • The server must assign a total order per chunk, which means one writer per chunk or a sequencer.
  • A little more client code.

Server sends the snapshot over the socket

On subscribe, the server pushes the current board, then the stream, in one ordered channel.

Pros

  • Ordering is free: the socket is one ordered pipe.

Cons

  • Megabytes per join go through the real-time tier instead of the CDN; a reconnect storm of 100,000 clients becomes up to about 200 GB from your socket servers.
  • No HTTP caching, no shared copies.

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.

Sequence diagram of loading one chunk, between the board worker, the socket tier and the CDN. The worker subscribes to chunk 0 on the socket. Updates 101 to 106 arrive and are buffered. The worker asks the CDN for latest.json and gets back seq 104 and the snapshot URL. Updates 107 to 112 arrive and are buffered. The snapshot PNG arrives and is drawn. The worker drops 101 to 104, applies 105 to 112, and goes live.Board workerSocket tierCDNsubscribe chunk 0updates 101 to 106: bufferGET latest.jsonseq 104, snap-104.pngupdates 107 to 112: bufferPNG arrives, drawndrop 101 to 104, apply 105 to 112, go live
The subscribe comes first, so the buffer covers the whole download. The snapshot's sequence number then decides which buffered updates are already in the picture and which are new.

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

ts
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;
}
Join a chunk: buffer the stream, then keep only updates newer than the snapshot.

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.

Replay rule
Joining a chunk with the snapshot at seq 104 and the sequence check rule, step 1 of 5. Buffered updates are seq 101 to 112. The client board is not drawn yet.990001020304050607080910111213141516seq 99 to 116 (last two digits)client boardserver at 112?3?4?2?1?1?1?4?1?3?1?2?3rule: sequence checkwrong pixels: n/astep 1 of 5hatched: buffered; faded: dropped; green: appliedoutlined: fetched to fill a gap; red: missing
Step 1: subscribed to the chunk first. The buffer will start at seq 101; nothing has arrived yet.

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?

Board-sized canvas, scaled with CSS

One canvas per chunk at its true size, 1,000 by 1,000, with one board pixel per canvas pixel.

Pixels are written with putImageData; zoom and pan are a CSS transform: translate() scale() on a wrapper.

image-rendering: pixelated keeps pixels as crisp squares when scaled up.

Pros

  • Pan and zoom never redraw: the compositor (the GPU step that moves layers) just moves and scales one texture.
  • Drawing code is simple and separate from camera code.
  • Memory is fixed at 4 bytes per board pixel, whatever the zoom.

Cons

  • Reddit's 2017 team found image-rendering support patchy then; it is fine in current browsers.
  • Zoomed out below 1x, nearest-neighbour scaling drops pixels and can shimmer while panning.
  • Each canvas must stay under device limits: iOS Safari caps a canvas at 16,777,216 pixels of area, the same as 4,096 by 4,096.

Viewport-sized canvas, redrawn with drawImage

Keep the board in an offscreen canvas. The visible canvas matches the screen size times devicePixelRatio.

Each frame that the camera moves, call drawImage with the visible source rectangle and imageSmoothingEnabled = false.

Pros

  • Works with any scaling quality setting; this was Reddit's 2017 fallback for blurry browsers.
  • Easy to draw overlays (grid lines, the reticle) into the same canvas.

Cons

  • Every pan frame is a full redraw on the main thread, a few ms on a phone.
  • Two bitmaps in memory: the board plus the screen-sized canvas.

WebGL with a palette shader

Upload the index array as a one-byte-per-pixel texture (4 MB for the whole board) and the palette as a tiny 32 by 1 texture.

A fragment shader looks up each screen pixel's index and its colour; the camera is a uniform.

Updates call texSubImage2D on the dirty rectangle only.

Pros

  • A quarter of the GPU memory of RGBA, and pan and zoom cost almost nothing.
  • Palette changes are one tiny upload; whole-board effects (a grid, highlighting one colour, heat maps) are a few shader lines.

Cons

  • More code, context loss to handle, and a fallback needed when WebGL is unavailable.
  • Texture size limits vary by GPU; query MAX_TEXTURE_SIZE and tile if needed.

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.

The rendering pipeline. A socket batch is written at once into the index and RGBA arrays, which marks dirty tiles. Once per animation frame, a flush copies only the dirty tiles into the chunk canvas with putImageData. Separately, pointer input sets a CSS transform with scale and translate, and the compositor on the GPU scales the canvas bitmap to the screen with pixelated sampling, so panning never redraws.socket batchevery messageindex + RGBAwrite at oncedirty tiles64 by 64rAF flushputImageDataframechunk canvas1000 by 1000CSS transformscale, translatecompositor (GPU)pixelatedpointer inputcamerapan never redraws
Two loops that never block each other: data flows along the top once per frame, and the camera only changes a 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.

ts
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;
}
Batch per frame and copy only the dirty rectangle.

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.

ts
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 };
}
Screen point to board pixel, with integer snapping and bounds.
Mapping a tap to a board pixel. A zoomed view shows board columns 1200 to 1204 and rows 486 to 488, each cell 56 screen pixels wide. A tap at screen point 238, 136 lands inside cell 1203, 487, which is highlighted. With scale 56, tx minus 67160 and ty minus 27156, x is floor of 67398 over 56, which is 1203, and y is floor of 27292 over 56, which is 487. Math.round would give 1204, one cell off.12001201120212031204486487488+scale 56tx -67160, ty -27156tap at (238, 136)x = floor(67398 / 56) = 1203y = floor(27292 / 56) = 487Math.round gives x = 1204one cell offthe tap, inside cell (1203, 487)
A cell spans from 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: none on the board so the browser does not scroll or zoom the page, and handle pointer events yourself; call setPointerCapture so 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 wheel event with ctrlKey set; treat it as zoom, not scroll. Desktop Safari also sends its own non-standard gesturechange events.
  • 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 tx and ty to 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.

Updates per frame
Reticle (5, 3): orange
A 12 by 8 board at zoom 1x after 0 frames, at seq 1000. 0 updates have caused 0 draw calls copying 0 pixels, with batching on. The reticle is at 5, 3 over a orange pixel. Cooldown left: 0 frames.212121121212212121121212frame 0updates 0draw calls 0copied 0 pxseq 1000ready to placeyou: (5, 3)dashed: this frame's dirty rectangle
Snapshot loaded at seq 1000. Press Next frame or Play to let simulated users paint.

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?

Every change to everyone, as it happens

Each accepted placement is published as one message to every connected client.

Pros

  • Lowest latency, simplest server.

Cons

  • Per-message overhead dominates a 5-byte payload.
  • Total bytes are changes times clients, with no way to shrink either.

Ticks: batch, coalesce and encode in binary

The server collects changes per chunk for a tick, say 200 ms, keeps only the last write per pixel, and sends one binary frame per tick.

Each change packs into 4 bytes: 10 bits of x and 10 of y within a 1,000 by 1,000 chunk, 5 bits of colour, plus a sequence range per frame.

Pros

  • About 10 times smaller than JSON, and one frame per tick instead of hundreds.
  • Coalescing drops overwritten pixels during battles over hot spots.

Cons

  • Adds up to one tick of latency, which nobody notices on a 5-minute cooldown.
  • Still sends every change to every client.

Region subscriptions

The client subscribes only to the regions it can see, plus a margin, and changes subscriptions as the camera moves. Regions can be smaller than storage chunks, say 500 by 500, so 16 cover the board.

Zoomed far out, it switches to an overview stream: a fresh downscaled snapshot every few seconds instead of every pixel.

Pros

  • A zoomed-in phone viewing 1 of 16 regions gets about one sixteenth of the traffic.
  • Zoomed out, single-pixel changes are smaller than a screen pixel, so nobody misses them.

Cons

  • Subscribe and unsubscribe churn while panning; each new region needs a snapshot-plus-deltas join.
  • More server bookkeeping per connection.

Socket sends pointers, the CDN sends bytes

The socket carries only a small message saying a new diff image is ready, with its URL and sequence.

Clients fetch the diff from the CDN. This matches what Reddit's 2022 clients received: full-frame and diff-frame image URLs per canvas.

Pros

  • The heavy bytes go through the CDN, which is built for fan-out, and identical requests are cached.
  • Socket servers stay light.

Cons

  • Two hops per update, so more latency.
  • Many small CDN requests per client per second; HTTP/2 or HTTP/3 helps.

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.

Fan-out per client. On the left, the board is split into 16 regions in a 4 by 4 grid; a phone's viewport covers 2 of them, which are subscribed, and the other 14 are not. On the right, bars compare bytes per second for one client at 2,000 changes per second: about 100 KB per second when every change is sent as JSON, about 6 KB per second as coalesced binary ticks, and under 1 KB per second with ticks for only the 2 visible regions. All values are estimates.viewport: 2 of 16 regionsevery change as JSONabout 100 KB/sbinary ticks, coalescedabout 6 KB/sticks + 2 visible regionsunder 1 KB/sper client, 2,000 changes per second
Multiply each bar by a million and a half open tabs. The two cheap tricks are encoding (smaller changes) and subscribing by region (fewer changes); together they cut per-client traffic by about a hundred times for zoomed-in phones.

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 offlineMissed changes (at 2,000 per second)What the client does
Under 1 secondunder 2,000Resume; the server replays from the ring buffer
1 to 60 seconds2,000 to 120,000Resume from the ring buffer, applied in one frame; or a snapshot if that is smaller
Over 60 secondsover 120,000Snapshot plus deltas per visible chunk, from the CDN
Server deploy, everyone at onceanyBackoff 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?

Wait for the server

Disable the button, show a spinner, paint the pixel only when the server confirms.

Pros

  • Never shows a wrong pixel.
  • Simplest state.

Cons

  • On a 4G phone the round trip is often 150 to 400 ms, longer at peak, and the spinner sits over the exact spot the user is watching.
  • Feels broken during a load spike, exactly when everyone is placing.

Optimistic paint into the board

Write the new colour into the board arrays at once; on rejection, write the old colour back.

Pros

  • Instant feedback.

Cons

  • The board arrays stop being the server's truth. If a remote update for that pixel arrives while ours is pending, the rollback restores the wrong colour.
  • Bugs here leave ghost pixels only one user sees.

Optimistic paint as a pending overlay

Draw the new colour in a separate overlay above the board, with a pending marker (a pulsing outline).

The board arrays only ever take colours from the server stream.

On success, the overlay is removed once the stream applies our sequence number; on rejection, it is simply removed.

Pros

  • Instant feedback and no rollback logic: the truth underneath was never touched.
  • Remote updates to the same pixel are applied normally and the server's order decides the final colour.

Cons

  • A second drawing layer to manage (tiny: usually one pixel).
  • A short moment where the overlay hides a newer remote colour.

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.

Sequence diagram of placing a pixel, between the UI, the placement service, the pixel API and the socket. The user taps Place for red at 40, 12. The placement service shows a pending overlay and sends POST /pixels with an idempotency key. On success the API answers 200 with seq 9001 and the next cooldown; later the socket delivers seq 9001 in the stream, and the placement service removes the overlay and starts the timer. On failure the API answers 429 cooldown with 112 seconds left, and the placement service removes the overlay and corrects the timer.UIPlacementPixel APISocketPlace: red (40, 12)pending overlayPOST /pixels, key200: seq 9001seq 9001 in streamdrop overlay, timeror, on failure:429 cooldown, 112 sdrop it, fix timer
Success needs two signals: the HTTP answer says the server accepted it, and the stream says the board now shows it. Failure needs only one, and since the board was never touched, removing the overlay is the whole rollback.

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.

State machine for placing. Ready goes to Sending when the user taps Place. Sending goes to Confirming on a 200 with a sequence number, and to Rejected on a 4xx. Confirming goes to Cooling when that sequence arrives in the stream. Cooling goes back to Ready when the server-given duration runs out. Rejected goes back to Ready, or to Cooling when the error was a cooldown. A frozen config moves any state to Frozen, which has no way out.ReadySendingConfirmingCoolingtap200seqtimer ends(server duration)Rejected4xxother error429: cooldownFrozenfrom any state
Every arrow is triggered by the server or by a timer started from a server duration. Nothing in the browser's clock or memory can move the button to Ready early.

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.

Architecture of the pixel canvas client. At the top are three server parts: the Pixel API, the CDN and the socket tier. The main thread holds the placement service, which posts to the Pixel API; the UI shell with palette and timer; the overlay with the reticle and who-placed card; and the camera controller for pointer, keys and the CSS transform. The board worker holds the sync client, which gets snapshots from the CDN and ticks from the socket tier; the chunk arrays with index, RGBA and dirty tiles; and the OffscreenCanvas that runs putImageData once per frame. The camera sends the viewport to the worker, and the worker sends sequence numbers and status to the overlay.Pixel APICDNSocket tierMain threadBoard workerPlacementkey, pending, cooldownUI shellpalette, timerOverlayreticle, cardCamerapointer, keys, transformSync clientregions, seq, catch-upChunk arraysindex, RGBA, dirty tilesOffscreenCanvasputImageData per framePOST /pixelssnapshotsticksseq,statusviewport
The line between the two zones is the most important one: everything bursty and byte-heavy lives in the worker, and everything the user touches lives on the main thread. Only two small messages cross it.
PartLives inOwnsWhy the boundary is here
Camera controllerMain threadPointer, wheel and keyboard input; scale and offset; the CSS transform; URL syncInput must answer within one frame, and the transform is applied to DOM
Board workerWeb Worker with OffscreenCanvasPer-chunk index and RGBA arrays, sequence counters, dirty tiles, drawingThe heavy, bursty work, kept off the input thread
Sync clientBoard workerSnapshot fetch, socket subscriptions per region, gap detection, catch-up, backoffClose to the arrays it writes, so updates never cross a thread twice
Placement serviceMain threadPlace requests, idempotency keys, the pending overlay, cooldown deadlineIt is user-facing state that the UI renders directly
Overlay layerMain thread DOMReticle, pending pixel marker, coordinates pill, who-placed cardA few elements positioned with the camera; easy to make accessible
UI shellMain thread ReactPalette, Place button, timer, banners, settingsOrdinary 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.

Data flow. Reads: the CDN serves snapshot PNGs and catch-up ranges over HTTP, and the socket tier pushes binary ticks; both go into the board worker, which checks sequence numbers and writes arrays. Once per frame the worker copies dirty tiles into the chunk canvas, whose bitmap the compositor shows through the camera transform. Writes: the user taps Place, the Place button sends POST with x, y and colour to the Pixel API, which validates, logs, and publishes to the socket tier; the API answers 200 with the sequence and the cooldown.CDNPNG, rangesSocket tierbinary ticksPixel APIvalidate, logBoard workerseq check, arrayschunk canvasdirty tilescompositorcamera transformPlace buttonpending overlayGET snapshotWebSocketper frameuser tapsPOST x, y, colour200: seq, cooldownMspublish
Reads and writes use different doors. A write comes back to its author the same way it reaches everyone else, through the stream, which is why the overlay waits for its sequence number.

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.

ts
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";
};
Client-side entities.

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.

http
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
HTTP endpoints and socket messages.

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?

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:

MinutesWhat to doWhat to say out loud
0 to 5Scope: board size, palette, users, cooldown, devices, endingThe pixel format drives memory, snapshot size and message size, so I will fix it first
5 to 10Numbers: 4 MB of indexes, 16 MB RGBA, changes per second, naive fan-outFan-out, not writes, is the cost; the cooldown does not save us
10 to 20Board model and loading: palette indexes, chunks, snapshot from the CDN plus sequenced deltasSubscribe first, buffer, replay only what is newer than the snapshot
20 to 30Rendering and camera: canvas per chunk, CSS transform, pixelated, dirty tiles per frame, workerPan never redraws; updates cost one small copy per frame
30 to 40Placement: pending overlay, server cooldown as a duration, atomic check, botsThe board arrays only ever hold server truth
40 to 50Fan-out: ticks, coalescing, binary, region subscriptions, reconnectDraw the architecture and data flow here
50 to 60Ending, history, accessibility, phones, follow-upsPick 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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next