A display refreshing at f Hz gives the main thread a fixed budget of 1000/f ms to finish everything a frame needs — layout, paint, compositing — before the next vsync. This sim generates a synthetic per-frame workload: a steady render cost, occasional garbage-collector pauses, and optional layout-thrash / slow-task spikes you trigger yourself. Whenever that workload exceeds the budget, the frame is janky — the browser can't present on time, so it either delays the frame or skips it, and the interval to the next real update grows accordingly. The strip along the bottom moves by (workload ms × its target speed) each generated frame rather than by a fixed pixel step, so a slow frame doesn't just get logged as a red bar — you see it as a visible jump in on-screen motion, which is what a user actually perceives as stutter.
budget = 1000 / targetFPS
workload = renderCost + jitter + (GC roll < p ? gcLen : 0) + spikes
janky = workload > budget
stripOffset += workload * pxPerMs // jump size scales with overrun
- Render cost — steady work a frame always does (layout + paint of the current scene); higher values eat directly into headroom.
- GC pause chance / length — models a garbage collector sweep landing mid-frame; low probability but a large one-off spike, the classic cause of an isolated dropped frame.
- Layout thrash — a one-off forced reflow (e.g. reading layout then writing style in a loop), added to the very next frame.
- Slow task — a long synchronous main-thread task (JSON parsing, a big loop), also charged to the next frame only.
Real-world relevance: this is exactly why "60fps" apps still feel janky under load — an average frame time well under budget can hide rare spikes that are the only frames a user actually notices.