When Canvas Stuttered on My 4K Monitor — DPR Cap + Blur Batching to the Rescue
Canvas ran smoothly on FHD but tanked on a 4K monitor. What DPR actually is, how blur cost scales, and the exact strategies I used to recover — with real numbers.
Starting Point — "Why Only on My Monitor?"
I had a handful of canvas-based works stacked up — Starship, Blackhole, and others. Most development and testing happened on a regular FHD laptop, and it felt plenty smooth.
Then I hooked up a 4K external monitor and tested again. The same works visibly stuttered. The profiler showed ctx.filter = 'blur(...)' calls eating over half of the frame budget.
That's where this post starts.
First — What Is DPR?
Device Pixel Ratio (DPR) is the ratio of physical pixels per CSS pixel.
- FHD regular monitor: DPR 1
- Retina / high-density displays: DPR 2
- 4K laptop built-in displays: DPR 2–3
HiDPI-aware canvas usually looks like this:
const dpr = window.devicePixelRatio;
canvas.width = cssWidth * dpr;
canvas.height = cssHeight * dpr;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
ctx.scale(dpr, dpr); // draw coordinates are now CSS px
This is the standard pattern to keep things crisp on high-density displays.
The Problem — Pixel Explosion on 4K + High DPR
On a 4K (3840×2160) monitor with DPR 2, a full-screen canvas hits:
cssWidth * dpr = 3840 * 2 = 7680
cssHeight * dpr = 2160 * 2 = 4320
→ total pixels: ~33.2 million
Compare with FHD at DPR 1: 1920 × 1080 ≈ 2.07 million. 16× difference.
Most draw operations survive that. The problem is blur:
blur cost ∝ pixel_count × blur_radius²
Double the radius → 4× cost. Combine with 16× pixels → 64× load. A handful of blur draw calls per frame blows past the 16 ms budget.
Strategy 1 — Cap the DPR
Simplest and most effective. Accept that "drawing at DPR 1.5 vs 2 on a 4K display is visually nearly indistinguishable to human eyes" and set an upper bound.
// utils/setupCanvasScale.ts
export function setupCanvasScale(
canvas: HTMLCanvasElement,
cssW: number,
cssH: number,
opts: { dprCap?: number } = {},
) {
const rawDpr = window.devicePixelRatio || 1;
const dprCap = opts.dprCap ?? 2;
const effectiveDpr = Math.min(rawDpr, dprCap);
canvas.width = Math.round(cssW * effectiveDpr);
canvas.height = Math.round(cssH * effectiveDpr);
canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}px`;
return { effectiveDpr };
}
The whole project standardized on this utility, and performance-sensitive pages (Starship, Blackhole) explicitly pass dprCap: 2.
⚠️ Important: never reference
window.devicePixelRatiodirectly inside a renderer. Always use theeffectiveDprreturned bysetupCanvasScale, so render scale and buffer size stay consistent.
Strategy 2 — Cap the Blur Radius Itself
Even with DPR capped, 4K resolution is still high, so also cap the blur radius itself — Math.min(minDim * 0.02, 20) style.
const minDim = Math.min(cssW, cssH);
const blurRadius = Math.min(Math.round(minDim * 0.02), 20);
ctx.filter = `blur(${blurRadius}px)`;
Beyond 20px, GPU blur falls off a cliff on many devices. The threshold varies by hardware, but 20 has been a reliable empirical cap.
Strategy 3 — Batching Blurs via OffscreenCanvas
This was the big win. When you have many blur targets, don't set ctx.filter = 'blur(...)' for each. Instead, draw them all to an OffscreenCanvas without blur, then drawImage that result back once with blur.
// module-level cache
let _offscreen: OffscreenCanvas | null = null;
function drawBlurredLayer(ctx: CanvasRenderingContext2D, items: Item[]) {
if (!_offscreen || _offscreen.width !== ctx.canvas.width) {
_offscreen = new OffscreenCanvas(ctx.canvas.width, ctx.canvas.height);
}
const off = _offscreen.getContext('2d')!;
off.clearRect(0, 0, _offscreen.width, _offscreen.height);
// 1. Draw everything without blur
for (const item of items) {
drawItem(off, item);
}
// 2. Blur once and composite to main
ctx.save();
ctx.filter = `blur(${blurRadius}px)`;
ctx.drawImage(_offscreen, 0, 0);
ctx.restore();
}
Measured on this project's Blackhole page:
| Approach | Blur ops / frame | Perceived FPS (4K) |
|---|---|---|
| Per-object blur | 438 | ~22fps |
| OffscreenCanvas batching | 7 | ~58fps |
62× fewer blur ops, FPS up ~2.5× — with the same visual result.
Strategy 4 — Caching Static Meshes / Vertices
While I was at it, I found another waste. Things like terrain or constellation layouts — meshes that don't need per-frame recomputation — were being regenerated every frame with jitter + hashing.
Cosmic Barrage's terrain triangulation was regenerating 2000+ vertices every single frame. Cached in stateRef:
interface GameState {
terrainVerts?: Vertex[][]; // optional cache
// ...
}
function drawTerrain(ctx: CanvasRenderingContext2D, state: GameState) {
if (!state.terrainVerts) {
// generate once on first call
state.terrainVerts = generateTerrain(...);
}
// reuse cached vertices
renderVerts(ctx, state.terrainVerts);
}
// Invalidate on stage change
function nextStage(state: GameState) {
state.terrainVerts = undefined;
}
2000+ Math.sin / Math.random calls per frame dropped to one-time, GC spikes vanished.
⚠️ Trap: When refactoring to a cache, be careful about helper function scope. A
hashdeclared inside theif (!cached) { ... }block won't exist on the cache-hit path (the else branch or subsequent loops) and throwsReferenceError. Declare helpers at the top of the function so both paths can reach them. TypeScript doesn't catch it — it only explodes at runtime on first cache hit, so browser smoke tests are mandatory.
Strategy 5 — Unified Glow Rendering
Small but surprisingly effective. For stars and glows, the usual approach is "core layer + glow layer" drawn on top of each other — which creates hard-edge seams.
// ❌ Hard-edge seam
drawGlow(ctx, x, y, 20);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
drawCore(ctx, x, y, 3);
Unifying into a single gradient eliminates the seam.
// ✅ One radialGradient combining core through glow
const g = ctx.createRadialGradient(x, y, 0, x, y, 20);
g.addColorStop(0, 'rgba(255,255,255,1)'); // core
g.addColorStop(0.3, 'rgba(200,220,255,0.8)'); // transition
g.addColorStop(1, 'rgba(100,150,255,0)'); // fade out
ctx.fillStyle = g;
ctx.fillRect(x - 20, y - 20, 40, 40);
Half the draw calls, and the visual quality is better.
Checklist
If canvas performance starts to worry you, walk through this list.
- Use a common
setupCanvasScaleutility — never referencewindow.devicePixelRatiodirectly - Performance-sensitive pages pass
dprCap: 2 - Keep blur radius ≤ 20px
- Multiple blur targets → batch into OffscreenCanvas, blur once
- Per-frame regenerated meshes → cache in state
- Stars / glows via unified radialGradient
Retrospective
"4K got slow" isn't just "high-res, what can you do" — it's due to blur's quadratic cost relation to radius. Once you see that, DPR capping and blur batching — two simple moves — solve most of it.
"Performance optimization is 80% analysis, 20% code change" — freshly felt. Understanding why it's slow at the formula level makes the actual work almost mechanical.
If you see the same symptom (fine on low-res, stutters only on high-res), start with the DPR cap. A 30-minute change often doubles FPS.
Guestbook
Leave a short note about this post
Loading...