sentencetogame.com

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 rateFrame budgetWhat exceeding it looks like
60 Hz16.7 msVisible stutter, controls feel heavy
90 Hz11.1 msStutter that appears only on newer phones
120 Hz8.3 msFine 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:


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:

PropertyWhy it costsWhat it does per frame
ctx.shadowBlurForces a separate blur pass per drawTurns one sprite into three or four operations
ctx.filterRe-filters the source on every callUncacheable; cost scales with sprite size
ctx.fillTextRasterises glyphs on every callHundreds of microseconds for a HUD
ctx.getImageDataForces a GPU-to-CPU readbackStalls 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:


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:

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...ComplexityMeaning
Roughly doublesLinearFine. You have headroom arithmetic, not a bug
Roughly quadruplesQuadraticYou are doing pairwise work somewhere
Barely movesNot your causeLook 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:


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.

  1. Cap device pixel ratio at 2. One line, biggest single win, no visual cost on flat art.
  2. Run the three probes — T1, T2, T0 — and write the three numbers down before you change anything else.
  3. Solve for G and C. You now know whether you are pixel bound or not, and you can stop reading half of this article.
  4. Delete shadowBlur, filter and getImageData from the frame loop.
  5. Hoist allocations out of the loop and pool your particles.
  6. Cap entity count. Then, only if doubling entities quadrupled your frame time, add a spatial grid.
  7. 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.