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
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
devicePixelRatiotransform. - 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
setTransformbeforedrawImage, or draw the buffer in raw device pixels. - Transparency expectations.
drawImagecomposites 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.
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.
Related
- Offscreen Canvas Rendering — the parent guide to off-thread and off-screen drawing.
- Transfer an OffscreenCanvas to a Web Worker — moving the whole draw off the main thread.
- Offscreen Canvas for Background Chart Updates — preparing frames ahead of when they are shown.