Building a Real Time PixelBoard with Cloudflare Durable Objects (and Keeping Infrastructure Costs at Zero)
I love digital messaging boards. There’s just something endlessly fascinating about giving visitors a blank canvas and letting them leave quick notes or small drawings. As someone who admittedly spent way too many hours watching Reddit’s r/place unfold years ago, I really wanted to capture a bit of that magic for my own site.
So I set out with a pretty clear goal in mind: build a real time doodle board while keeping infrastructure costs strictly at zero. Since my domain was already on Cloudflare, I figured I could use Cloudflare Durable Objects and R2 object storage to handle the entire backend without leaving their free tier.
You can try out the live board here: Draw something on the PixelBoard!
Core Design Decisions
Before writing any code, I had to figure out what kind of experience I wanted to build. Here are a few early design choices that shaped both the user experience and the underlying architecture:
Canvas Size (500x500 vs. 250x250)
I initially started with a 500x500 pixel canvas (250,000 pixels total). It sounded great in theory, but in practice, it was way too big. A canvas that massive meant drawings felt sparse and isolated. Scaling down to 250x250 pixels (62,500 pixels total) struck a much better balance: detailed enough for some cool pixel art, but compact enough to feel like a lively collaborative space.
Colour Palette (Black & White vs. Multiple Colours)
I debated whether to support a full palette of 16 or 32 colours. But I kept coming back to the idea of a minimalist blackboard. Limiting the palette strictly to black & white keeps interactions simple, gives the canvas a nostalgic “chalk on slate” vibe, and avoids colour clutter. Plus, keeping it binary set things up nicely for a fun data packing trick I was eager to try out.
Interaction Model (Single Click Toggle vs. Brush Tools)
Because I settled on a black & white canvas, there was no need for a complex colour selector UI or brush tools. Instead of click and drag painting, I opted for a simple click interaction: a single click on a pixel turns it white (chalk), and clicking it again turns it back to black (slate).
Designing the Blackboard and Grid Overlay
Visual design matters a lot when you want an interface to feel tactile. I wanted a clean, dark slate blackboard look paired with sharp pixel grid lines.
Getting those grid lines to render cleanly took a bit of trial and error though.. My original approach was trying to add borders around individual pixels, but that got messy fast x__x. I ultimately settled on using two stacked canvas layers in the browser. The base canvas handles pixel colours, while a transparent canvas overlay handles grid rendering. Separating these layers keeps rendering super fast because we don’t redraw the grid every single time someone clicks a pixel.
Shrinking 62,500 Pixels into 7.8 Kilobytes
A canvas size of 250 by 250 pixels comes out to a total of 62,500 individual pixels on the board.
If you store each pixel as a standard JavaScript object with x, y, and colour properties, memory usage inflates quickly. Raw JSON payloads waste massive amounts of bandwidth, so I wanted to aim for something much lighter.
Since I decided early on that the blackboard would only allow two states per pixel (black background or white chalk), we can use bit packing! Instead of spending a whole byte or string per pixel, we can store eight pixels in a single byte.
A little quick math: 62,500 pixels divided by 8 bits per byte equals 7,813 bytes. That is under 8 kilobytes for the entire canvas state!
// 62,500 pixels packed into a 7,813 byte Uint8Array
const WIDTH = 250;
const HEIGHT = 250;
const TOTAL_PIXELS = WIDTH * HEIGHT;
const BUFFER_SIZE = Math.ceil(TOTAL_PIXELS / 8); // 7,813 bytes
function setPixelByIndex(buffer, pixelIndex, colorBit) {
const byteIndex = Math.floor(pixelIndex / 8);
const bitOffset = pixelIndex % 8;
if (colorBit === 1) {
buffer[byteIndex] |= (1 << (7 - bitOffset));
} else {
buffer[byteIndex] &= ~(1 << (7 - bitOffset));
}
}
When someone draws a stroke, the browser sends a tiny 3-byte binary packet over WebSockets. Bytes 0 and 1 carry the 16-bit pixel index, while byte 2 carries the colour bit.
// 3 byte binary stroke payload
export function encodeDelta(pixelIndex, colorBit) {
const packet = new Uint8Array(3);
packet[0] = (pixelIndex >> 8) & 0xff;
packet[1] = pixelIndex & 0xff;
packet[2] = colorBit ? 1 : 0;
return packet;
}
The Free Tier Trap: Quota Math and Latency
To keep drawings intentional, I added a 100ms cooldown between pixel clicks. This forces visitors to draw pixel by pixel instead of holding down a button to paint over everything instantly.
All was working well, until I checked Cloudflare’s free tier limits.. haha
Cloudflare Durable Objects give us 1,000,000 free storage write operations per month. That sounds huge at first glance, but at a 100ms click rate, a single user drawing continuously sends 10 pixels a second. That is 600 writes a minute, or 36,000 writes an hour.
Now, I doubt my website is getting hit with massive traffic, but it’s still good to do the math. If people draw actively for just 28 hours total across an entire month, the free tier quota for my account gets completely used up! And on top of quota depletion, writing to storage on every single click creates unnecessary I/O latency.
First Attempt: A Multi-layer Storage Setup
So how do you provide instant real time interaction without burning through database write quotas?
I’ve found that decoupling live state updates from storage writes is the key. In my initial build, I split persistence into four separate layers:
- In Memory Delta Broadcast (RAM): WebSocket messages update the Durable Object’s in memory ArrayBuffer (
this.boardBuffer) and broadcast immediately to all connected clients. Latency is under 1ms, and storage cost is zero. - Throttled Durable Object Storage: Active drawing marks state as dirty, but we throttle
state.storage.putcalls to once every 1,000ms. This cuts storage write volume by 90% while keeping data safe. - Decoupled R2 Snapshot Backups: Every 60 seconds during active drawing, we push a snapshot of the canvas buffer to Cloudflare R2 (
env.CANVAS_R2.put). This creates reliable long term backups without hitting R2 Class A write limits. - On Disconnect State Drain: What happens if a user closes their tab before the 1 second save timer fires? When active WebSocket sessions drop to zero, we immediately run
flushStorage()so no pending drawing state gets left behind.
Here is how that initial save throttling logic looked in JavaScript:
scheduleSave() {
this.dirty = true;
if (!this.saveTimeout) {
// Throttle DO storage saves to 1,000ms
this.saveTimeout = setTimeout(async () => {
this.saveTimeout = null;
await this.flushStorage();
}, 1000);
}
if (this.env && this.env.CANVAS_R2 && !this.r2Timeout) {
this.r2Dirty = true;
// Backup snapshot to R2 every 60s
this.r2Timeout = setTimeout(async () => {
this.r2Timeout = null;
await this.flushR2();
}, 60000);
}
}
Pushing Efficiency Further: Advanced Storage Optimizations
The initial multi layer setup worked well, but I was still worried about incurring costs. I couldn’t stop thinking.. can we optimize this even more?
A fixed 60 second R2 timer fires every minute during active drawing, even if someone only painted a single pixel and walked away. Meanwhile, saving to Durable Object storage every 1,000ms still totals 3,600 write operations an hour. On top of that, standard JavaScript setTimeout timers can get dropped if Cloudflare hibernates the Durable Object isolate to save resources.
To solve this without losing a single pixel of drawing data, I changed the storage pipeline with some targeted tweaks:
- Idle Debouncing for R2 Backups
- Instead of backing up to R2 on a rigid 60 second timer, we now reset a 10 second timer on every stroke. When drawing pauses for 10 seconds, we save to R2 once. If someone draws continuously for 5 minutes straight, a safety fallback triggers an R2 flush so backups never get stale.
- Relaxed Durable Object Throttling (3,000ms)
- We increased the DO storage save interval from 1,000ms to 3,000ms. Since the in memory RAM buffer handles live client sync and we flush on disconnect anyway, this cuts database writes by another 66% with zero risk.
- Cloudflare Native Alarms (
setAlarm)- Cloudflare Durable Objects feature a built-in alarm API (
this.state.storage.setAlarm()). Unlike in memory timers, alarms are durable. Cloudflare guarantees it will wake up the object to run thealarm()handler even if the isolate goes to sleep.
- Cloudflare Durable Objects feature a built-in alarm API (
- Edge Cache Headers for Snapshots
- For public
/snapshotrequests from new visitors, we added aCache-Control: public, max-age=5, s-maxage=5header. Now Cloudflare’s CDN network handles initial canvas loads directly, saving backend compute overhead.
- For public
flowchart TD
A["Client WebSocket Stroke"] -->|3 Byte Binary Delta| B["Durable Object RAM Buffer"]
B -->|< 1ms Sync| C["Connected WebSocket Clients"]
B -->|Throttled 3s Flush| D["Durable Object Storage"]
B -->|10s Idle Debounce / 5m Cap| E["Cloudflare R2 Bucket"]
F["Durable Object Hibernation"] -->|Durable setAlarm| G["alarm() Native Handler"]
G -->|Flush Storage & R2| D
G -->|Flush Storage & R2| E
H["Session Count Drops to 0"] -->|Disconnect Drain| D
H -->|Disconnect Drain| E
Here is the updated save scheduling code in CanvasRoom.js:
// Native DO Alarm Handler (Guaranteed execution by Cloudflare runtime)
async alarm() {
await this.flushStorage();
await this.flushR2();
}
scheduleSave() {
this.dirty = true;
this.r2Dirty = true;
const now = Date.now();
// 1. Throttle DO storage saves to 3,000ms
if (!this.saveTimeout) {
this.saveTimeout = setTimeout(async () => {
this.saveTimeout = null;
await this.flushStorage();
}, 3000);
}
// 2. Idle Debounce R2 Save: Save 10s after drawing stroke stops
if (this.env && this.env.CANVAS_R2) {
if (this.r2Timeout) clearTimeout(this.r2Timeout);
const MAX_R2_INTERVAL = 300000; // 5 minute max cap
const timeSinceLastR2 = now - this.lastR2SaveTime;
if (timeSinceLastR2 > MAX_R2_INTERVAL) {
this.flushR2();
} else {
this.r2Timeout = setTimeout(async () => {
this.r2Timeout = null;
await this.flushR2();
}, 10000);
}
// Schedule DO durable alarm for guaranteed backup if DO hibernates
if (this.state && this.state.storage && typeof this.state.storage.setAlarm === 'function') {
try {
this.state.storage.setAlarm(now + 15000);
} catch (e) {
// Ignore in environments without alarms
}
}
}
}
What’s Next?
The board runs great on desktop browsers, but mobile definitely still needs work. Touch controls are tricky.. touch events on mobile often trigger canvas drawing when you just meant to pan across the screen.
Here are a few updates I plan to explore next..
Mobile Touch Controls
Separating pan gestures from drawing strokes using explicit multi-finger touch detection or a dedicated pan toggle button.
Pinch to Zoom Scaling
Adding smooth pinch gestures so mobile users can zoom into pixel clusters effortlessly without disturbing the canvas position.
Canvas Expansion Tiers
Exploring larger canvas sizes or multiple room channels so different communities can host their own pixel blackboards.
Wrapping Up
It’s been a super fun little project to put together! Building with a strict zero cost goal forces some pretty creative engineering choices, but I’m generally pretty happy with how it turned out. By combining bit packing with a multi-layered caching strategy, I managed to get a snappy, real time pixel blackboard running comfortably inside Cloudflare’s free tier limits.
Feel free to leave a drawing or note on the board: PixelBoard Live Demo