loke.dev
Header image for The Desynchronized Frame

The Desynchronized Frame

Input lag in web drawing apps is often treated as an unavoidable law of physics, but the desynchronized canvas hint allows you to bypass the compositor and draw directly to the glass.

· 4 min read

I spent a week building what I thought was the smoothest drawing app on the web, only to realize that the stylus line followed my hand like a tired puppy—always about two inches behind. I checked my event listeners, optimized my math, and even tried to move the logic into a Web Worker, but the "mushiness" remained. I assumed this was just the "Web Tax," a physical law dictated by the browser's rendering engine that we all just had to accept.

Then I found out we’ve been waiting in a line we didn't have to stand in. The culprit wasn't my code; it was the compositor.

The 16.6ms Tax

When you draw on a standard <canvas>, your pixels don't go straight to the screen. They go through a polite, bureaucratic process called the Compositor Thread.

The browser wants everything to look nice and synchronized. It waits for the next "frame" to happen, aligns your canvas update with your CSS animations, scrolls, and other DOM changes, and then pushes it all to the GPU. This is usually great. It prevents "tearing" and keeps things smooth. But for drawing, that synchronization is a latency nightmare. By the time the browser is ready to show your line, your hand has already moved another 10 or 20 pixels.

Bypassing the Gatekeeper

Enter the desynchronized hint. When you grab your 2D or WebGL context, you can tell the browser: "Stop trying to sync me. I want to talk to the screen right now."

const canvas = document.querySelector('canvas');

// The magic happens in the attributes object
const ctx = canvas.getContext('2d', {
  desynchronized: true,
  alpha: false // This is a crucial partner for desynchronized
});

By setting desynchronized: true, you’re essentially asking for a "low-latency" backbuffer. In some browsers (like Chrome on ChromeOS or Windows), this allows the browser to bypass the main compositor entirely and draw directly to the front buffer or use "overlays."

The result? The line starts sticking to your stylus like glue.

Why alpha: false is your best friend

You’ll notice in the snippet above I set alpha: false. This isn't just a performance suggestion—it's almost a requirement for the best results.

If your canvas is transparent, the browser *must* composite it with whatever is behind it (like the background of your <body>). That forced marriage requires the compositor to step in, which can negate the latency gains of desynchronization. By setting alpha: false, you're telling the browser the canvas is an opaque block, making it much easier to "fast-track" those pixels to the glass.

Dealing with the "Flicker"

When you go desynchronized, you're living life on the edge. Because you're bypassing the usual double-buffering synchronization, you might occasionally see "tearing" or a half-drawn frame if you aren't careful.

If you’re doing a complex "clear and redraw" cycle every frame, you might see a white flash. The solution is to use the canvas.transferToImageBitmap() pattern or simply be very surgical with your drawing.

For most drawing apps, you aren't clearing the whole screen every frame; you're just adding a segment. Here is a pattern for a low-latency "trailing" line:

let lastCoord = null;

canvas.addEventListener('pointermove', (e) => {
  if (e.buttons !== 1) return;

  const x = e.offsetX;
  const y = e.offsetY;

  if (lastCoord) {
    ctx.beginPath();
    ctx.moveTo(lastCoord.x, lastCoord.y);
    ctx.lineTo(x, y);
    ctx.stroke();
  }

  lastCoord = { x, y };
});

canvas.addEventListener('pointerup', () => {
  lastCoord = null;
});

The Super-Power: getCoalescedEvents()

If you’re building a professional-grade drawing tool, desynchronized is only half the battle. Your screen might refresh at 60Hz or 120Hz, but your modern digitizer (like an Apple Pencil or a Wacom tablet) is actually sending data much faster than that.

Standard pointermove events only fire as often as the browser's main thread ticks. But tucked away in the event object is a history of everywhere the pointer went between frames.

canvas.addEventListener('pointermove', (e) => {
  // Grab all the intermediate points the browser "missed"
  const points = e.getCoalescedEvents ? e.getCoalescedEvents() : [e];
  
  for (let point of points) {
    drawPoint(point.pageX, point.pageY);
  }
});

Combine desynchronized: true with getCoalescedEvents(), and you’ve moved from a "web app that feels okay" to "software that feels native."

Is there a catch?

Always.

First, desynchronized is a hint, not a command. The browser can choose to ignore you if the system doesn't support it. Second, it can behave strangely if you have CSS transforms (like rotate or scale) applied to the canvas element itself. Because you're bypassing the compositor, the browser might struggle to map your "direct-to-screen" drawing onto a rotated element.

My advice: If you use desynchronized, keep your canvas styling simple. No weird CSS filters, no complex 3D transforms. Just a flat, opaque box that wants to be painted as fast as humanly possible.

It's one of those rare "one-line" changes that fundamentally changes the user experience. If you’re building anything where a user moves a mouse or a pen and expects to see a result instantly, stop fighting the compositor and just go desynchronized.