Double-Buffering Canvas to Remove Flicker

Your real-time chart flashes: for a split second each frame the canvas goes blank or shows a half-drawn scene, then snaps to the finished image, and the flicker is worst when the frame takes a long time to draw.

The cause is that you are clearing and repainting directly on the visible canvas, so the display can composite a frame mid-draw. This page is a companion within offscreen canvas rendering: the fix is double buffering — assemble the entire next frame on a hidden buffer, then copy the completed image to the on-screen canvas in a single drawImage blit. The viewer only ever sees whole frames, never the intermediate clear-and-redraw. Where transferring an OffscreenCanvas to a Web Worker moves drawing off the main thread, double buffering keeps drawing on-thread but hides its intermediate states.

Diagnostic checklist

Draw to an offscreen buffer then blit A hidden buffer is cleared and fully drawn while the visible canvas keeps the previous frame, then the finished buffer is blitted in one operation. Assemble hidden, show whole offscreen buffer (hidden) clearRect draw grid draw series draw labels frame complete blit one drawImage visible canvas only whole frames shown without buffering clear on screen composite mid-draw = flicker
The offscreen buffer is cleared and drawn to completion while the visible canvas still shows the previous whole frame; a single blit then swaps in the finished image, so no partial state ever reaches the screen.
Per-frame time vs the 16.7ms budget Measured-style frame durations with bars over budget marked in red. frame #ms/frame16.7ms budget (60fps)
Frame durations for canvas to remove flicker against the 16.7ms (60fps) budget — the red bars are the janky frames a fix must remove.

Broken vs fixed

// ❌ BROKEN: clear and draw directly on the visible canvas.
function renderOnScreen(ctx: CanvasRenderingContext2D, scene: Scene): void {
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); // screen goes blank NOW
  drawGrid(ctx, scene);      // each of these may be visible mid-frame
  drawSeries(ctx, scene);    // a slow scene shows a half-drawn image
  drawLabels(ctx, scene);    // → flicker between blank and complete
}
// ✅ FIXED: draw to an offscreen buffer, blit the finished frame once.
class DoubleBufferedCanvas {
  private readonly buffer: HTMLCanvasElement;
  private readonly bctx: CanvasRenderingContext2D;

  constructor(private readonly view: CanvasRenderingContext2D) {
    this.buffer = document.createElement("canvas");
    // PERF: match the visible backing store so the blit is a straight 1:1 copy.
    this.buffer.width = view.canvas.width;
    this.buffer.height = view.canvas.height;
    this.bctx = this.buffer.getContext("2d")!;
  }

  render(scene: Scene): void {
    // All clearing and drawing happens off screen; the viewer sees none of it.
    this.bctx.clearRect(0, 0, this.buffer.width, this.buffer.height);
    drawGrid(this.bctx, scene);
    drawSeries(this.bctx, scene);
    drawLabels(this.bctx, scene);
    // A11Y: keep the accessible data table in sync here, not per draw call.

    // One blit publishes the whole frame; the screen never shows a partial state.
    this.view.drawImage(this.buffer, 0, 0);
  }
}

The insight is that flicker is a visibility problem, not a drawing-speed problem. Clearing the visible canvas immediately shows blank, and each subsequent draw call is a separate mutation the compositor can sample between. By moving every clear and draw onto a hidden buffer, the visible canvas is untouched throughout — it keeps displaying the last complete frame — until the single drawImage replaces it wholesale. drawImage from one canvas to another is a fast, effectively atomic GPU copy, so there is no window in which the screen holds a half-built scene.

Note that modern browsers already double-buffer canvas compositing to some degree, so a fast single-pass draw often looks fine without this. Explicit double buffering earns its keep when the scene takes long enough to draw that the intermediate state becomes observable, or when you draw across multiple requestAnimationFrame-independent code paths that would otherwise each hit the screen.

Step-by-step fix

function blit(view: CanvasRenderingContext2D, buffer: HTMLCanvasElement): void {
  // PERF: a single drawImage is an atomic-looking copy; no partial frame reaches screen.
  view.drawImage(buffer, 0, 0);
}

Verification

// The buffer must match the visible backing store so the blit does not scale.
const dbc = new DoubleBufferedCanvas(view);
console.assert(
  (dbc as unknown as { buffer: HTMLCanvasElement }).buffer.width === view.canvas.width,
  "buffer width does not match visible canvas",
);
// After a render the visible canvas is non-blank (last op was a full blit).
dbc.render(scene);
const px = view.getImageData(0, 0, 1, 1).data;
console.assert(px[3] !== 0 || sceneIsEmpty(scene), "visible canvas blank after render");

In DevTools, open the Rendering panel and enable paint flashing, or capture a Performance recording and step through frames: with double buffering the visible canvas transitions directly from one complete frame to the next, never through a blank or half-drawn state. Slow the CPU to exaggerate draw time and confirm the flicker is gone.

When a buffer is worth it, and the OffscreenCanvas variant

Double buffering trades a little memory and one extra copy per frame for flicker-free output, so it is not automatic. A single fast draw pass that completes well inside the frame budget rarely flickers because the browser composites once at the end of the rAF callback. The technique pays off in three situations: scenes heavy enough that a partial draw is visible; renderers that emit to the screen from more than one place per frame; and any design where you want to keep the previous frame visible while computing an expensive next one, then reveal it atomically.

OffscreenCanvas is the natural buffer type here because it exists purely as a drawing surface with no DOM attachment, and its transferToImageBitmap produces an ImageBitmap you can hand to the visible context’s drawImage or transferFromImageBitmap on a bitmaprenderer context — an even cheaper publish path than blitting a full canvas. It also composes with worker rendering: a worker can own the offscreen buffer, draw the whole frame there, and post an ImageBitmap to the main thread for a one-line blit, which keeps both the drawing cost and the flicker off the main thread at once.

Edge cases & gotchas

  • Buffer larger than needed. Allocating a buffer bigger than the visible canvas wastes memory and forces the blit to downscale; keep the two backing stores identical.
  • DPR mismatch. If the visible canvas is DPR-scaled but the buffer is not, the blit stretches and blurs; size and scale the buffer with the same devicePixelRatio transform.
  • Reallocating per frame. Creating a new offscreen canvas each frame allocates a fresh backing store and churns the GC; create once and only resize on a real size change.
  • Forgetting to clear the buffer. The buffer retains the previous frame’s pixels; clear it before each redraw or ghosting from the last frame bleeds through translucent areas.
  • Blitting with an active transform. A leftover pan or DPR transform on the visible context offsets the blit; reset with setTransform before drawImage, or draw the buffer in raw device pixels.
  • Transparency expectations. drawImage composites the buffer over whatever is on the visible canvas; if you want a replace rather than an over, clear the visible canvas first or the buffer’s transparent regions show the stale frame.
SVG vs Canvas render-cost crossover Frame cost against element count for a DOM-node SVG chart and a single-element Canvas. element count (×1k)frame costSVG (DOM nodes)Canvas (1 element)
The render-cost crossover underlying canvas to remove flicker: SVG cost climbs with node count while Canvas stays flat, which is why heavy scenes switch engines.

Frequently Asked Questions

Why does clearing the canvas cause a flash?

Because clearRect on the visible canvas takes effect immediately, so the screen shows blank until your draw calls repaint it, and the browser can composite a frame during that gap. The longer the scene takes to draw, the wider the window in which a blank or half-drawn image is visible. Drawing off screen and blitting the finished frame closes that gap entirely.

Doesn’t the browser already double-buffer the canvas?

To an extent — a single draw pass inside one requestAnimationFrame callback usually composites once at the end, so fast scenes rarely flicker on their own. Explicit double buffering matters when the scene is heavy enough that intermediate draws become observable, or when drawing reaches the screen from several code paths per frame. In those cases assembling the frame on a hidden buffer guarantees only whole frames appear.

Should I use an OffscreenCanvas or a hidden canvas element as the buffer?

Either works; an OffscreenCanvas is preferable because it has no DOM overhead and offers transferToImageBitmap for a very cheap publish to a bitmaprenderer context. It also lets a Web Worker own the buffer and post a finished ImageBitmap to the main thread, moving both the drawing cost and the flicker off the main thread in one design.