How to track stutters and crashes in a browser game
The Glitchless team · October 27, 2026 · Web games in current browsers, with three.js examples
Time every frame from requestAnimationFrame and report the typical frame, the 95th percentile and the worst every few seconds. Log each frame over 50 ms as a hitch, with what changed during it, so you can name a likely cause. Catch errors with error and unhandledrejection listeners and a lost GPU context with webglcontextlost, group them by fingerprint with the game events just before each one, send everything in batches and once more when the tab is hidden, and upload source maps so minified stacks point at your code.
Each piece is a few lines of plain JavaScript, below. The examples use three.js where a renderer is involved; the rest works with any engine. For what to measure beyond stutters and crashes (levels, your own events, purchases, retention), see What analytics to add to a Three.js game.
The short answer
- Frame times: the
requestAnimationFrametimestamp minus the last one. Every 5 seconds, keep the median, the 95th percentile, the worst and how many frames went over 50 ms. - Long frames: a
PerformanceObserverforlong-animation-frame(Chrome and Edge 123 and later) names the script behind a slow frame;longtaskis the older, vaguer fallback. Firefox and Safari support neither. - Causes: compare counters from before and after the slow frame. New shader programs mean a shader compiled, new textures or geometries an upload, a shrinking heap a garbage collection.
- Errors: listen for
error(with capture, to see failed loads),unhandledrejectionandwebglcontextlost, and group them by kind, message and top function, each with the last few game events. - Sending: batches on a timer, then
navigator.sendBeaconwhenvisibilitychangesays the page is hidden, the last moment a page can count on. - Source maps: build them hidden, upload them for each version, and read stacks through them where the errors arrive.
Time every frame
The callback you pass to requestAnimationFrame gets a timestamp: the end of the previous frame's rendering, in the same milliseconds as performance.now(). The difference between two of them is the frame time. In three.js, the time that setAnimationLoop passes to your callback is that same timestamp.
Don't send each frame. Keep them in an array and every 5 seconds summarise: the median (the typical frame), the 95th percentile, the worst, and how many went over 33.3 ms (two missed refreshes at 60 Hz) and over 50 ms (the usual line for a hitch). Tag the summary with the level.
Two things to watch. Browsers pause requestAnimationFrame in background tabs, so the first frame back spans the whole time away: drop it. And on a 120 Hz screen a 16.7 ms frame already misses a refresh, so judge frames against the display's own rate, which you can read off the fastest frames.
frames.js
// frames.js: frame times from requestAnimationFrame, summarised every 5 seconds.
export const windows = [];
let times = [];
let last = null;
let level = null;
// rAF stops while the tab is hidden, and the first frame back would span the whole time away.
document.addEventListener('visibilitychange', () => { last = null; });
// Once per frame with rAF's timestamp (in three.js, the `time` setAnimationLoop passes).
// Returns this frame's time in ms, or null when there's nothing to compare it with.
export function frameTime(now) {
const dt = last === null ? null : now - last;
last = now;
if (dt !== null) times.push(dt);
return dt;
}
export function setLevel(name) {
closeWindow(); // a window never spans two levels
level = name;
}
export function closeWindow() {
if (times.length >= 10) {
const s = times.sort((a, b) => a - b);
const at = (q) => Math.round(s[Math.min(s.length - 1, Math.floor(s.length * q))] * 10) / 10;
windows.push({
level, frames: s.length, p50: at(0.5), p95: at(0.95), worst: at(1),
over_33ms: s.filter((t) => t > 33.3).length,
over_50ms: s.filter((t) => t > 50).length,
});
}
times = [];
}
setInterval(closeWindow, 5000);
A device that's slow all the time shows up in the percentiles; a hitch is a frame much slower than the ones around it. Counting a frame as a hitch only when it's over 50 ms and over twice the recent median keeps a slow laptop from logging a hitch every frame.
Long animation frames and long tasks
Frame times say a frame was slow, not why. In Chrome and Edge 123 and later, a PerformanceObserver for long-animation-frame reports every frame whose rendering was delayed beyond 50 ms, with its blockingDuration, when rendering and style and layout started, and scripts: each script over 5 ms that ran in it, with its sourceURL, sourceFunctionName, what invoked it and its duration.
The older longtask entry type reports any main-thread task of 50 ms or more, but only says which frame or iframe it ran in, not which script. Use it as a fallback in older Chromium. Firefox and Safari support neither, so feature-detect with PerformanceObserver.supportedEntryTypes and fall back to frame times and the counters in the next section.
| Entry type | What it tells you | Supported in |
|---|---|---|
long-animation-frame | Frames delayed beyond 50 ms, with each script over 5 ms that ran in them | Chrome and Edge 123 and later |
longtask | Main-thread tasks of 50 ms or more, and the frame or iframe they ran in | Chromium browsers (Chrome 58 and later) |
| Neither | Use frame times and the renderer's counters | Firefox and Safari |
long-frames.js
// long-frames.js: which script made a frame long, where the browser can say.
export const longFrames = [];
const types = globalThis.PerformanceObserver?.supportedEntryTypes ?? [];
export const loafSupported = types.includes('long-animation-frame');
function keep(entry) {
longFrames.push(entry);
if (longFrames.length > 100) longFrames.shift();
}
if (loafSupported) {
// Chrome and Edge 123+: frames delayed beyond 50 ms, with each script over 5 ms that ran in them.
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
keep({
start: e.startTime,
end: e.startTime + e.duration,
blocking_ms: Math.round(e.blockingDuration),
scripts: e.scripts
.map((s) => ({
fn: s.sourceFunctionName || s.invoker,
file: s.sourceURL.split(/[?#]/)[0].split('/').pop(),
ms: Math.round(s.duration),
}))
.sort((a, b) => b.ms - a.ms)
.slice(0, 3),
});
}
}).observe({ type: 'long-animation-frame', buffered: true });
} else if (types.includes('longtask')) {
// Older Chromium: main-thread tasks of 50 ms or more, without the script's name.
new PerformanceObserver((list) => {
for (const e of list.getEntries()) keep({ start: e.startTime, end: e.startTime + e.duration, scripts: [] });
}).observe({ type: 'longtask', buffered: true });
}
Entries reach the observer a moment after the frame, so keep the last hundred and match them to hitches by time a little later, as the next section does. Long animation frames only see the page's main thread: a slow frame with no entry covering it most likely spent its time on the GPU, in the compositor or elsewhere outside the page's scripts.
Attach a likely cause to each hitch
A cause turns a hitch into something you can fix. Take a snapshot of a few counters at the start of every frame; when a frame turns out slow, compare the snapshot from its start with the counters now. What changed is the likely cause. More than one can be true and none of them is proof, but with the level and the last few game events they usually point at the fix.
| Cause | What you see in the slow frame | Usual fix |
|---|---|---|
| Shader compile | renderer.info.programs.length went up: a material was drawn for the first time | Compile while loading with renderer.compileAsync(): see first-use shader hitches |
| GPU upload | renderer.info.memory.textures or geometries went up: new textures or meshes reached the GPU | Upload before play with renderer.initTexture(), and reuse geometries |
| Garbage collection | The JS heap shrank by a few MB (performance.memory, which is non-standard and Chromium only) | Stop allocating in the loop: see hitches every few seconds |
| Script | A long animation frame covering it names a script that ran for tens of milliseconds | Spread the work over several frames, or move it to a worker |
| Asset load | A load was in flight (THREE.DefaultLoadingManager between onStart and onLoad) | Load and decode before the level starts |
| GPU or OS | The browser supports long animation frames and none covers the slow frame | Fewer pixels, simpler shaders, lighter post-processing |
hitch-cause.js
// hitch-cause.js: likely causes for each slow frame, from what changed while it ran.
import * as THREE from 'three';
import { longFrames, loafSupported } from './long-frames.js';
let loading = false; // chain these with your own handlers if you already set them
THREE.DefaultLoadingManager.onStart = () => { loading = true; };
THREE.DefaultLoadingManager.onLoad = () => { loading = false; };
let pending = [];
let prev = null;
function counters(renderer) {
return {
programs: renderer.info.programs?.length ?? 0, // shader programs compiled so far
textures: renderer.info.memory.textures, // textures on the GPU
geometries: renderer.info.memory.geometries,
heap: performance.memory?.usedJSHeapSize ?? null, // non-standard, Chromium only
};
}
// At the start of every frame, with rAF's time, the frame time and the last game events.
export function checkFrame(renderer, now, dt, recent) {
const cur = counters(renderer);
if (prev && dt > 50) {
const causes = [];
if (cur.programs > prev.programs) causes.push('shader_compile');
if (cur.textures > prev.textures || cur.geometries > prev.geometries) causes.push('gpu_upload');
if (cur.heap !== null && prev.heap !== null && cur.heap < prev.heap - 2e6) causes.push('gc');
if (loading) causes.push('asset_load');
pending.push({ start: now - dt, end: now, ms: Math.round(dt), causes, before: recent.slice(-5) });
}
prev = cur;
}
// Long animation frame entries arrive a moment after the frame, so match them later.
export function takeHitches(all = false) {
const cutoff = all ? Infinity : performance.now() - 1000;
const done = pending.filter((h) => h.end < cutoff);
pending = pending.filter((h) => h.end >= cutoff);
for (const h of done) {
const loaf = longFrames.find((l) => l.end >= h.start && l.start <= h.end);
if (loaf?.scripts.length) {
h.causes.push('script');
h.scripts = loaf.scripts;
} else if (loafSupported && !loaf && !h.causes.length) {
h.causes.push('gpu_or_os'); // the main thread wasn't blocked
}
}
return done;
}
// main.js: in the loop
renderer.setAnimationLoop((time) => {
const dt = frameTime(time);
if (dt !== null) checkFrame(renderer, time, dt, recent);
update();
renderer.render(scene, camera);
});
Also attach counts of what's on screen (enemies, particles) and the names of the game events in the second or two before the hitch. Knowing that hitches come when 300 particles spawn is a fix you can make.
Catch errors, rejected promises and a lost WebGL context
Two listeners on window and two on the canvas catch nearly everything that breaks in a browser game:
errorgives the message, file, line and column, and the thrownErrorwith its stack. Add it with capture (trueas the third argument): images, scripts and stylesheets that fail to load fireerroron their own element, and it doesn't bubble up towindow.unhandledrejectionfires for a promise that rejected with nothing to catch it, such as a failedfetchor a loader's promise. The error is inevent.reason.webglcontextlostfires on the canvas when the GPU context is gone: after a GPU reset, a switch between GPUs, a driver update, or when pages together ask too much of the GPU. CallpreventDefault()so the browser can restore it; afterwebglcontextrestored, every texture, buffer and shader made before is invalid. three.js'sWebGLRenderercallspreventDefault()itself and sets its WebGL state up again when the context comes back.- A script served from another domain, such as a CDN, passes only minimal information to these listeners, usually
Script error.with no stack, unless it's loaded withcrossorigin="anonymous"and served with anAccess-Control-Allow-Originheader.
errors.js
// errors.js: uncaught errors, failed loads, rejected promises and a lost WebGL context.
import { report } from './report.js';
window.addEventListener('error', (e) => {
if (e.target !== window) {
// An <img>, <script> or <link> that failed to load: these don't bubble, hence capture below.
report({ kind: 'asset', message: `failed to load ${e.target.src || e.target.href}` });
return;
}
report({ kind: 'js', message: e.message, error: e.error, source: e.filename, line: e.lineno, col: e.colno });
}, true);
window.addEventListener('unhandledrejection', (e) => {
const err = e.reason instanceof Error ? e.reason : null;
report({ kind: 'promise', message: err ? err.message : String(e.reason), error: err });
});
const canvas = renderer.domElement;
canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault(); // lets the browser restore it (three.js does this too)
report({ kind: 'webgl_context_lost', message: 'WebGL context lost' });
});
canvas.addEventListener('webglcontextrestored', () => {
// Every texture, buffer and shader made before is gone: rebuild them, or reload the level.
});
An uncaught error inside the render loop is its own kind of crash. A hand-written loop that calls requestAnimationFrame at the end of its callback stops for good when the callback throws, and the game freezes on its last frame. three.js's setAnimationLoop asks for the next frame before it calls yours, so there the error repeats every frame instead. Either way, count a session that ends within a few seconds of an uncaught error, with nothing played after it, as a crash, not just an error.
Group errors by fingerprint, with the events before them
One bug throws the same error for every player who hits it, often every frame. Give each error a fingerprint so a thousand reports become one issue with a count: the kind, the message with numbers and hex addresses masked, and the name of the function at the top of the stack. Leave the file name out, since a bundler renames index-3f2a.js on every build. In a minified build the function's name changes from build to build too, until source maps give back the real one (below).
Keep the names of the last 20 or so game events (level started, boss spawned, shop opened) and attach the last few to each error. Those events, more than the stack, are usually what lets you reproduce it. Cap how many times one fingerprint is sent per session, and strip query strings from any address in the message or stack: they can carry tokens.
report.js
// report.js: one fingerprint per bug, the game events before it, and a cap per session.
import { queue } from './send.js';
export const recent = []; // names of the last game events, newest last
const sent = new Map(); // fingerprint -> times sent this session
export function remember(name) { // call wherever the game tracks something: level_start, boss_spawn
recent.push(name);
if (recent.length > 20) recent.shift();
}
const scrub = (s) => String(s ?? '').replace(/\?[^\s:)]*/g, ''); // URLs lose their query strings
function topFunction(stack) {
for (const line of stack.split('\n')) {
const m = line.match(/^\s*at (?:async )?([^\s(]+) \(/) || line.match(/^([^@\s]+)@/); // Chrome; Firefox, Safari
if (m) return m[1];
}
return '';
}
export function report({ kind, message, error, source, line, col }) {
const msg = scrub(message).slice(0, 500);
const stack = scrub(error?.stack).slice(0, 4000);
// The kind, the message with numbers and hex masked, and the top function. No file name:
// the bundle's name changes with every build.
const fingerprint = [kind, msg.replace(/0x[0-9a-f]+|\d{3,}/gi, '#'), topFunction(stack)].join('|');
const n = (sent.get(fingerprint) ?? 0) + 1;
sent.set(fingerprint, n);
if (n > 25) return; // an error in the loop fires every frame
queue({ type: 'error', kind, message: msg, stack, source: scrub(source), line, col, fingerprint, before: recent.slice(-6) });
}
Send it when the tab closes
Send in batches on a timer, every 15 seconds or so, and once more when the page is hidden. visibilitychange to hidden is the last event a page can reliably see, on phones included; unload, beforeunload and pagehide may not fire on mobile, for example when a player switches apps and the system later closes the browser.
At that moment use navigator.sendBeacon(url, body): the browser queues the request and sends it even after the page is gone. It returns false when it couldn't queue the data, and the data it queues is limited to 64 KiB; fetch with keepalive: true is the fallback, with the same 64 KiB limit on its body. So keep batches small. A string body is sent as text/plain, which needs no CORS preflight to a collector on another domain.
send.js
// send.js: a batch every 15 seconds, and a last one when the page is hidden.
import { windows, closeWindow } from './frames.js';
import { takeHitches } from './hitch-cause.js';
const ENDPOINT = 'https://collect.example.com/v1/events';
const VERSION = '1.4.0'; // source maps are looked up by it
const session = Math.random().toString(36).slice(2, 14); // per page load, kept in memory only
let batch = [];
export function queue(event) {
batch.push({ ts: Date.now(), ...event });
}
function send(closing) {
if (closing) closeWindow();
for (const w of windows.splice(0)) queue({ type: 'frames', ...w });
for (const h of takeHitches(closing)) queue({ type: 'hitch', ...h });
if (!batch.length) return;
const body = JSON.stringify({ session, version: VERSION, events: batch }); // a string goes as text/plain
batch = [];
if (closing && navigator.sendBeacon(ENDPOINT, body)) return; // queued: sent even after the page is gone
fetch(ENDPOINT, { method: 'POST', body, keepalive: true }).catch(() => {});
}
setInterval(() => send(false), 15000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') send(true);
});
Source maps for minified builds
A production build's stack reads something like e (index-3f2a.js:1:48213). A source map turns that position back into your file, line and function. Build the maps, keep them off your public server, and do the mapping where the errors arrive.
With Vite, build.sourcemap: 'hidden' writes the .map files without the comment in the bundle that points browsers at them. Upload them for each version you ship, before players get it, send that version with every error, and look each stack up in the maps for its version. Compute the fingerprint from the original function name, so one bug stays one issue from build to build.
Sentry does this with its setup wizard, its Vite plugin or sentry-cli, and links maps to builds through debug IDs it adds to the build output.
vite.config.js
// vite.config.js: maps are written, but nothing in the bundle points players' browsers at them.
import { defineConfig } from 'vite';
export default defineConfig({
build: { sourcemap: 'hidden' },
});
// Then for each release, in CI:
// 1. upload dist/**/*.js.map under the version the game reports
// 2. delete the .map files from dist
// 3. deploy dist
Break it on purpose
Before you trust the numbers, cause each problem yourself and check it arrives with the right kind, a cause and the events before it. three.js can drop the WebGL context on demand with renderer.forceContextLoss() and bring it back with renderer.forceContextRestore(); both need the WEBGL_lose_context extension.
debug-key.js
// Behind a debug key in the game. Each should arrive with its kind, a cause and the events before it.
const until = performance.now() + 200;
while (performance.now() < until) {} // a 200 ms hitch
setTimeout(() => { throw new Error('test: uncaught'); });
Promise.reject(new Error('test: unhandled rejection'));
renderer.forceContextLoss(); // needs the WEBGL_lose_context extension
setTimeout(() => renderer.forceContextRestore(), 2000);
The same with the Glitchless SDK
The Glitchless browser SDK does all of the above from one init call and instrumentThree; Phaser, Babylon.js and PlayCanvas have adapters too. It times every frame, counts a hitch when a frame is over 50 ms and over twice the recent median, and gives it likely causes: a script named by Long Animation Frames, a shader compile, a GC pause, an asset load, and with three.js what the frame sent to the GPU (textures, geometry buffers, shader programs). Where the browser reports long animation frames and none covers a slow frame, the cause is gpu_or_os.
It catches uncaught errors, unhandled rejections and WebGL context loss (calling preventDefault() for you), fingerprints them by kind, message and top function, and sends a batch every 15 seconds and one with sendBeacon when the page is hidden. The glitchless-sourcemaps Vite plugin builds hidden maps, uploads them after vite build when GLITCHLESS_API_KEY is set, and with deleteMaps: true keeps them out of dist.
glitchless.js
import * as THREE from 'three';
import { Glitchless, instrumentThree } from 'glitchless';
const glitchless = Glitchless.init({
service: 'my-game',
version: '1.4.0', // source maps are uploaded under the same version
intake: 'https://api.glitchless.gg',
clientToken: 'gl_pub_...',
});
instrumentThree(glitchless, { renderer, scene, camera, THREE });
// Every frame: counts that show next to each hitch
glitchless.setContext({ enemies: enemies.length, particles: particleCount });
// An error you caught yourself
glitchless.error(err, { where: 'save_game' });
// vite.config.js
import glitchlessSourcemaps from 'glitchless-sourcemaps/vite';
export default defineConfig({
plugins: [glitchlessSourcemaps({ deleteMaps: true })],
});
Every player's stutters and crashes in one place
On your machine the game may never hitch. Glitchless puts the hitches and errors from every player's session together: frame times by level, GPU tier, browser and version; each hitch with its causes, what was on screen and the game events in the 1.5 seconds before it; and errors grouped into issues, ranked by the sessions they hit, each with the six game events before it. A session counts as crashed when the WebGL context was lost for good, when it ended during a freeze, or when it ended within 10 seconds of an unhandled error with nothing played after it. With source maps uploaded by glitchless-sourcemaps, a minified frame like e (index-3f2a.js:1:48213) reads fireShotgun (weapons.js:141), and one bug stays one issue across builds.
Your coding agent reads the same over MCP: glitchless_triage ranks what to fix first, with the evidence, glitchless_get_error opens an issue with its original stack and the events before it, and glitchless_performance breaks frame times and hitch causes down by level, GPU tier, browser and version.
Ask your coding agent
Use Glitchless to find the worst stutters and crashes in my browser game: rank them with glitchless_triage, open the top error with glitchless_get_error and read the events before it, check the hitch causes on the slowest level with glitchless_performance, fix the top problem, and tell me how to confirm the fix in the next version.
Frequently asked questions
What counts as a stutter in a browser game?
A frame that takes much longer than the ones around it. A common line is 50 ms, about three refreshes at 60 Hz, which is also the threshold browsers use for long tasks and long animation frames. Frames over 33.3 ms, two missed refreshes at 60 Hz, are worth counting too.
Why do my errors only say "Script error."?
The script that threw came from another origin, such as a CDN, without CORS, and browsers pass only minimal information to window.onerror for such scripts. Load it with crossorigin="anonymous" and serve it with an Access-Control-Allow-Origin header.
How do I find what caused a stutter in Firefox or Safari?
Neither supports long animation frames or long tasks, so you lose the script's name there. Frame times, the renderer's counters (shader programs, textures, geometries) and the game events before a hitch work in every browser. For the script, reproduce it in Chrome, or record a profile in Firefox's or Safari's developer tools.
Does Sentry track stutters in a browser game?
Partly. Sentry's browser SDK catches uncaught errors and unhandled rejections by default and handles source maps, and its tracing marks long tasks and long animation frames as spans. It doesn't summarise every frame into percentiles or tie a hitch to a shader compile or a texture upload. Many teams use Sentry for errors across their code and a game-specific tool for frame times: see Glitchless vs Sentry.
What happens when the WebGL context is lost?
The canvas stops drawing and everything on the GPU (textures, buffers, shaders) is gone. If your handler called preventDefault(), the browser may restore the context and fire webglcontextrestored, and the game has to create its GPU resources again. If it never comes back, the session is over: count it as a crash.
Sources
- MDN: Window.requestAnimationFrame()
- MDN: PerformanceLongAnimationFrameTiming
- MDN: PerformanceScriptTiming
- Chrome for Developers: Long Animation Frames API
- MDN: PerformanceLongTaskTiming
- MDN browser compatibility data: long animation frames
- MDN browser compatibility data: long tasks
- MDN: Performance.memory
- MDN: Window error event
- MDN: HTMLElement error event
- MDN: The script element (crossorigin)
- MDN: Window unhandledrejection event
- MDN: webglcontextlost event
- MDN: webglcontextrestored event
- MDN: WebGLRenderingContext.isContextLost() (why contexts are lost)
- MDN: Navigator.sendBeacon()
- MDN: Document visibilitychange event
- MDN: RequestInit keepalive
- W3C Beacon: CORS-safelisted content types
- Vite: build.sourcemap
- three.js docs: WebGLRenderer (info, forceContextLoss)
- three.js source: WebGLAnimation.js (setAnimationLoop)
- three.js source: WebGLRenderer.js (context loss handlers)
- Sentry: Source maps for JavaScript
- Sentry: GlobalHandlers integration
- Sentry: Automatic instrumentation (long tasks, long animation frames)
- glitchless-sourcemaps on npm
- Glitchless browser SDK (README)
Something wrong or out of date? Email support@glitchless.gg and we'll fix it. Glitchless isn't affiliated with the three.js authors, the Vite team, Sentry, Google, Microsoft, Mozilla or Apple; product names are their owners' trademarks.