Why Is My Game Laggy? Four Causes and How to Prove Which One It Is
Short answer: a browser game is laggy for one of exactly four reasons — too many pixels shaded per frame, too much cost per draw call, garbage collection from per-frame allocation, or a simulation whose cost grows faster than its entity count. They look identical from the player's seat and they have almost nothing in common. You do not need a profiler to separate them. You need three frame-time readings taken under three conditions, one subtraction, and about ten minutes. Guessing between the four is how people spend an evening optimising the wrong system and then conclude the engine is slow.
Everything below is a procedure, not a list of tips. Do the procedure first; the tips are what you apply once you know which cause you have.
Measure frame time, not FPS
FPS is the wrong instrument, and it is wrong in a way that hides the answer. A game pinned at 60 reports 60 whether it uses 4 ms of its 16.7 ms budget or 16.6 ms. It reports 60 right up until it drops to 42, and then it reports a number that tells you nothing about why. Frame time in milliseconds is the same information without the ceiling, and it is the unit your budget is actually denominated in.
Three budgets matter, and they are not the same budget:
| Refresh rate | Frame budget | What exceeding it looks like |
|---|---|---|
| 60 Hz | 16.7 ms | Visible stutter, controls feel heavy |
| 90 Hz | 11.1 ms | Stutter that appears only on newer phones |
| 120 Hz | 8.3 ms | Fine on your desk, broken on the reviewer's device |
That third row is the trap. A build that holds 14 ms per frame is comfortable at 60 Hz and fails at 120 Hz, and the person testing it is usually on the faster screen. Budget to 8 ms if you intend to ship anywhere, because you cannot control the device.
Here is the whole instrument. Paste it into the console with the game running and in the state you care about — mid-wave, particles alive, not the title screen.
// Six seconds of frame times, then p50 / p95 / max.
(() => {
const t = []; let last = performance.now(); const stop = last + 6000;
function tick(now) {
t.push(now - last); last = now;
if (now < stop) requestAnimationFrame(tick);
else {
const s = [...t].sort((a, b) => a - b);
const q = p => s[Math.floor(s.length * p)].toFixed(1);
console.log(`n=${s.length} p50=${q(.5)} p95=${q(.95)} max=${s[s.length-1].toFixed(1)}`);
}
}
requestAnimationFrame(tick);
})();
Read three numbers, not one. p50 is the steady state. p95 is what the player actually perceives as smoothness, because a game that is fast 95% of the time and slow 5% of the time reads as broken rather than as fast. max tells you whether the slow frames are isolated hitches or a second, worse mode of operation.
A p50 of 12 ms with a max of 90 ms is not "a game that runs at 12 ms." It is a game with a periodic 90 ms freeze, and no amount of shaving the steady state will fix it.
The one subtraction that names the cause
Before touching code, split your frame time into the part that scales with pixels and the part that does not. This is the step almost nobody takes, and it converts a guessing game into arithmetic.
Take a reading at the game's normal canvas size. Call it T1. Now halve both canvas dimensions, so the pixel area becomes one quarter while all your game logic, entity counts and draw calls stay exactly as they were. Take a second reading. Call it T2. Then:
T1 = C + G (C = cost independent of pixels, G = cost proportional to pixels)
T2 = C + G/4 (pixel area is now one quarter)
T1 - T2 = 3G/4
G = 4 × (T1 - T2) / 3
C = T1 - G
That is the whole method. Worked example: T1 is 28 ms and T2 is 10 ms. Then G = 4 × 18 / 3 = 24 ms, and C = 4 ms. Twenty-four of your twenty-eight milliseconds are pixel work. You are fill-rate bound, and no amount of refactoring your collision loop will move that number.
The probe:
const c = document.querySelector('canvas');
const w = c.width, h = c.height;
c.width = Math.round(w / 2); c.height = Math.round(h / 2);
// run the six-second probe, note T2
c.width = w; c.height = h; // restore
Two things will look wrong while the canvas is quartered, and both are expected. Setting canvas.width resets the context, so your transform, fillStyle and smoothing settings are gone and the frame will look broken. And the game will appear to run at the same speed, because you changed the backing store, not the logic. You are not evaluating the picture. You are evaluating one number.
The interesting reading is the one that does not fit. If T1 − T2 is close to zero, the cost is not in pixels at all — it is per draw call, per entity, or per frame regardless of size, and you should skip straight to causes 2 and 4. If T2 comes out larger than T1, you are inside measurement noise and your frame time is small enough that the cause is probably not on the GPU side. Both outcomes are results, not failures. The equation only works when the assumption holds; when it does not hold, the violation tells you which side of the CPU/GPU line you are on.
One more baseline worth taking: comment out every draw in your render function and leave only the clear. Call it T0. If T0 is already 9 ms, most of your budget is being spent before your game draws anything — a full-page CSS animation, a background video, an enormous canvas being composited by the browser, or a second tab eating the GPU. Fix that first; it is free.
Cause 1: fill rate — you are shading too many pixels
This is the most common cause in generated 2D games, and the reason is a single line that appears in nearly every one of them:
canvas.width = canvas.clientWidth * devicePixelRatio;
canvas.height = canvas.clientHeight * devicePixelRatio;
On a phone with a device pixel ratio of 3, that line multiplies your pixel count by nine relative to a ratio of 1. Do the arithmetic on a mid-range handset: a 390 × 844 CSS canvas becomes 1170 × 2532, which is 2.96 million pixels of backing store. With three layers of overdraw — background, sprites, particles — you are writing 8.9 million pixels per frame. At 60 fps that is 533 million pixel writes per second. Capping the ratio at 2 drops the backing store to 1.32 million and the per-second figure to 237 million, a 56% cut, for a visual difference most players cannot identify on flat-colour art.
Test. Halve both dimensions and re-measure. If G from the equation above accounts for more than 60% of T1, this is your cause.
Fixes, in order of payoff:
- Cap the ratio:
const dpr = Math.min(devicePixelRatio, 2). One line, largest single win available to you. - Render at 1× and upscale with
image-rendering: pixelated. For flat-colour sprites this reads as a deliberate art style rather than a downgrade. - Find your overdraw. A full-screen gradient plus a full-screen vignette plus a translucent HUD is three full-screen passes before a single sprite exists. Bake the static layers into one offscreen canvas once and blit it.
- Cut particles by count, not by size. Particle systems in generated builds routinely spawn 40 per hit with additive blending, which is the most expensive combination canvas 2D offers.
- Stop clearing what you fully repaint. If your background covers the whole frame,
clearRectis wasted work.
Cause 2: per-draw cost — each call is expensive, not each pixel
The opposite failure. Your canvas is small, your entity count is modest, and you are still at 30 ms. The cost is attached to the number of draw operations rather than to their area, which is why shrinking the canvas did nothing.
Four properties dominate here, and generated code uses all four by default because they look good in a screenshot:
| Property | Why it costs | What it does per frame |
|---|---|---|
ctx.shadowBlur | Forces a separate blur pass per draw | Turns one sprite into three or four operations |
ctx.filter | Re-filters the source on every call | Uncacheable; cost scales with sprite size |
ctx.fillText | Rasterises glyphs on every call | Hundreds of microseconds for a HUD |
ctx.getImageData | Forces a GPU-to-CPU readback | Stalls the pipeline, often 5-15 ms alone |
getImageData deserves special mention because it is invisible in code review: it appears inside a per-pixel effect, a hit-test, or a "pixel perfect collision" helper, and it single-handedly converts a 4 ms frame into a 25 ms frame by forcing the GPU to hand pixels back to the CPU every frame.
Test. Replace every drawImage(sprite, ...) with fillRect for one run, keeping the same count and positions. If frame time collapses, you are per-draw bound. If it barely moves, your draws are not the problem and you should be looking at cause 3 or 4.
Fixes:
- Delete
shadowBlurandfilterfrom anything drawn more than ten times per frame. If you need the look, bake the shadow into the sprite once, at import. - Cache text. Render each HUD string to a small offscreen canvas when the value changes, then blit it. Scores change ten times a second, not sixty.
- Never call
getImageDatainside the frame loop. If you need a mask, compute it once at load. - Draw from a single atlas so consecutive
drawImagecalls share a source texture. - Batch by state. Every
fillStyleorglobalAlphachange is a state switch; group your draws by state instead of interleaving them.
Cause 3: per-frame allocation — the sawtooth
The signature is unmistakable once you know it: p50 is fine, p95 is three to five times p50, and the slow frames arrive at quasi-regular intervals rather than randomly. That regularity is a garbage collector running because you are creating garbage at a steady rate.
Generated code allocates constantly in the loop without ever meaning to. A {x, y} literal for a particle. A .filter() that returns a new array every frame. "Score: " + score building a string sixty times a second. arr.map() for a collision candidate list. Each one is trivial. Together they fill the young generation in a few seconds, and then the collector runs and you lose a frame or two.
Test. Freeze the game — no spawning, no input, nothing moving except what already exists — and run the probe. If spikes persist with nothing happening, the allocation is in the update or draw path, not in your gameplay events. Then check the timing pattern: evenly spaced spikes mean steady allocation, and the interval tells you the rate.
Fixes:
- Hoist every object literal and array out of the loop. Reuse one scratch array and set
length = 0instead of allocating a new one. - Replace
filter/map/slicein the hot path with a manual loop that writes into a preallocated array. - Build HUD strings only when the underlying value changes.
- Pool your particles. Allocate a fixed array of 200 at load, mark them alive or dead, and never create another.
- Avoid closures created per frame — a callback defined inside the loop is an allocation, even when it looks free.
A build that fixes only this often goes from p95 of 55 ms to p95 of 18 ms while the p50 does not move at all. If your p50 does not move but your p95 does, you fixed the right thing.
Cause 4: simulation cost — the curve is not a line
The last cause is the one people assume they have, and it is the least common of the four. It has one clean test: double the entity count and re-measure.
| If frame time when you double entities... | Complexity | Meaning |
|---|---|---|
| Roughly doubles | Linear | Fine. You have headroom arithmetic, not a bug |
| Roughly quadruples | Quadratic | You are doing pairwise work somewhere |
| Barely moves | Not your cause | Look at 1, 2 or 3 |
The quadratic case is almost always collision. Two hundred entities checked pairwise is 19,900 pairs per frame; four hundred is 79,800 — four times the work for twice the objects. At 60 fps that is 4.8 million pair tests per second, which a browser will do, and 200 entities is where a generated build usually starts to feel heavy on a phone.
Fixes:
- A uniform spatial grid is thirty lines and turns the pair count from n² into roughly n. Cell size at your largest sprite dimension.
- Cap your entity count and recycle the oldest. Most generated shooters have no cap at all, and the difficulty curve is accidentally a performance curve.
- Only check what can move. Static walls do not need to be in the moving-entity loop.
- Run expensive AI on a stagger — a quarter of the entities per frame, four-frame rotation. Nobody can see the difference.
When frame time is fine and it still feels laggy
This case is worth separating out, because it sends people into optimisation loops that cannot succeed. Your p50 is 8 ms, your p95 is 11 ms, and the game still feels wrong. Three things cause that, and none of them is performance.
Speed is tied to refresh rate. If your update uses a fixed increment per frame — x += speed with no delta time — the game runs at the same speed on a 60 Hz screen and twice as fast on a 120 Hz one. Players describe this as laggy, broken, or "the physics are weird," and it is none of those; it is a unit error. Multiply by dt in seconds and clamp dt to something like 0.05 so a backgrounded tab does not teleport everything on return.
Frame pacing, not frame time. Frames arriving at 16, 16, 33, 16 ms produce the same average as 20, 20, 20, 20 and look far worse, because motion is judged on the gaps. If your p50 is good and your p95 is double, you have a pacing problem: something is doing work on some frames and not others. That is almost always allocation or a synchronous operation triggered by an event.
Input mapping on a scaled canvas. If your pointer coordinates are read in CSS pixels but your game logic runs in backing-store pixels, every click lands at the wrong place by a factor of your device pixel ratio. On desktop with a ratio of 1 it works perfectly; on a phone it feels like the game ignores you. Scale by canvas.width / rect.width and the problem disappears.
The order to fix things in
If you have thirty minutes, spend them in this order. Each step is cheap and each one either fixes the problem or eliminates a cause.
- Cap device pixel ratio at 2. One line, biggest single win, no visual cost on flat art.
- Run the three probes — T1, T2, T0 — and write the three numbers down before you change anything else.
- Solve for G and C. You now know whether you are pixel bound or not, and you can stop reading half of this article.
- Delete
shadowBlur,filterandgetImageDatafrom the frame loop. - Hoist allocations out of the loop and pool your particles.
- Cap entity count. Then, only if doubling entities quadrupled your frame time, add a spatial grid.
- Re-measure with the same probe, in the same game state, on the same device. A fix you did not measure is a belief.
A note on the numbers here: the millisecond budgets are arithmetic from the refresh rate, and the pixel counts are arithmetic from a stated 390 × 844 CSS canvas at a stated ratio. They are not benchmarks from a device lab, and your hardware will differ. The procedure is the part that transfers — the equation does not care what your absolute numbers are, only how they move when you change one variable at a time.
Lag is not a mystery and it is not a property of the engine. It is one of four arithmetic facts about your frame, and you can identify which one in ten minutes with a console, a stopwatch, and the willingness to measure before you rewrite.