How I built a real-time multiplayer pixel art editor with Socket.IO, Redis, and WebGL.
pixeledit.net is a collaborative pixel art editor for the browser. This post covers the two hard problems: rendering pixel art with WebGL, and keeping multiple users in sync on a shared canvas.
Canvas 2D works for basic drawing, but PixelEdit needs 16x zoom, grid overlays, onion skinning, and tool previews running simultaneously. Canvas 2D redraws everything on every zoom or pan. With WebGL (via Regl), only the layers that actually changed get updated and the GPU composites them. On 128x128+ canvases with multiple layers, the performance difference is significant.
Benefits:
Drawbacks:
getImageDataEach project supports multiple frames with independent layers, duration, and timeline position. Frames can be reordered, duplicated, and given per-frame timing. Preview modes include loop, ping-pong, and play-once. Onion skinning shows ghost overlays of adjacent frames for alignment.
Sprite sheet export supports Unity (JSON with frame coords and pivot points), Godot (atlas format), Phaser, and a generic grid layout with configurable padding. The pipeline renders each frame to an offscreen canvas, composites them into the sheet, and generates the metadata file.
function calculateSheetLayout(frames: Frame[], columns: number, padding: number) {
const rows = Math.ceil(frames.length / columns);
const cellWidth = frames[0].width + padding * 2;
const cellHeight = frames[0].height + padding * 2;
return {
width: cellWidth * columns,
height: cellHeight * rows,
frames: frames.map((frame, i) => ({
x: (i % columns) * cellWidth + padding,
y: Math.floor(i / columns) * cellHeight + padding,
w: frame.width,
h: frame.height,
})),
};
}GIF export uses gif.js with web workers, APNG uses upng-js. Both handle per-frame timing for frames with variable durations.
The collaboration layer is event-based. A pixel change emits a pixel-update with the frame ID, coordinates, and color. The server validates it, broadcasts to other clients in the room, and caches the change in Redis.
socket.emit('pixel-update', {
frameId: currentFrame.id,
x: 42,
y: 17,
color: [255, 100, 50, 255]
}, (ack) => {
pendingUpdates--;
});
socket.on('pixel-update', ({ frameId, x, y, color, userId }) => {
if (userId !== myUserId) {
applyPixelToCanvas(frameId, x, y, color);
}
});I chose Socket.IO over raw WebSockets for its reconnection handling - WiFi drops, sleeping laptops, and backgrounded tabs are all handled automatically.
Drawing generates a lot of events. Dragging a brush across 50 pixels produces 50 updates in under a second, multiplied by the number of collaborators.
Sending every change immediately worked with one or two users but dropped events under load. The fix was client-side batching: when too many updates are in flight without acknowledgment, the client queues them and flushes in batches on a 16ms interval, aligned with the browser's frame rate. Local strokes still render instantly since they're applied before sending. Remote strokes appear within one frame.
Server-side rate limiting caps at 100 events/second per socket.
The data flow between client, cache, and database works in three tiers:
User draws pixel
→ Applied to local canvas (instant)
→ Emitted via Socket.IO
→ Server writes to Redis (fast)
→ Project marked dirty
→ SyncScheduler (every 30s) writes to PostgreSQL (durable)Client holds the freshest state. Frame data loads from PostgreSQL on project open; real-time updates apply directly to the in-memory canvas.
Redis receives every pixel update immediately. Frame data is stored as Base64 blobs keyed by project and frame ID with a 24-hour TTL. Redis also handles pub/sub for multi-server broadcasting and tracks which projects have unsaved changes.
PostgreSQL is the durable store. A sync scheduler runs every 30 seconds, checks what's dirty, and writes with a distributed lock.
A hard crash could lose up to 30 seconds of work. Writing to Postgres on every update would mean hundreds of writes per second during active sessions. Redis absorbs the burst and periodic sync keeps database load manageable.
Individual pixel updates are commutative - order doesn't matter since each pixel is independent. But compound operations like flood fill and paste are not. Two users flood-filling overlapping regions simultaneously produce different results depending on order.
The solution was treating compound operations as atomic batches. CRDTs would be more correct, but the implementation complexity isn't justified at the current scale.
Benefits:
Drawbacks:
Socket.IO + Redis pub/sub makes horizontal scaling straightforward. Each server subscribes to Redis channels for its active rooms. Updates publish to Redis, and other servers forward them to their connected clients. Adding capacity is a configuration change.
Everything - drawing tools, animation, export - works without a server connection. Collaboration is a layer on top. If a connection drops, the editor keeps working and resyncs when it reconnects.