Quick answer: JavaScript canvas games crash through uncaught errors and rejected promises that escape your loop. Hook window.onerror for synchronous errors and the unhandledrejection event for async ones, since each catches what the other misses. Keep source maps so minified production stacks deminify to real names, and capture browser, OS, and device context, which is where web game crashes vary most. Group by signature to handle the long tail of browsers.
A JavaScript canvas game runs inside a browser you do not control, on a device you have never seen, against an engine version that may differ from the one you tested. When an error escapes your requestAnimationFrame loop, the canvas often just freezes or goes blank with no visible message, and the only sign anything broke is in a console the player will never open. Capturing those errors and shipping them somewhere you can read them is what turns a silent freeze into a fixable bug. This post covers window.onerror, the unhandledrejection event, source maps for production stacks, and the browser context that makes web game crashes diagnosable.
Errors that escape the loop
In a canvas game your draw and update code runs inside a requestAnimationFrame callback, and an exception thrown there does not propagate up to anything you wrote; it unwinds out of the callback and is reported only to the browser. The visible result is usually that the next frame never schedules, so the canvas freezes on the last drawn image. To the player the game has hung with no explanation. Catching these requires hooking the browser's global error reporting, because there is no call stack of yours left to catch them by the time they escape the frame.
JavaScript also has two distinct failure channels that need separate hooks. A synchronous throw, the classic uncaught exception, surfaces through window.onerror. An error inside a promise that nobody caught, increasingly common in games that load assets or use async APIs, does not fire onerror at all; it fires the unhandledrejection event instead. A setup that only hooks onerror will silently miss every async failure, which in a modern game using fetch, audio, and dynamic imports can be a large fraction of crashes. Both hooks are required for real coverage.
window.onerror and unhandledrejection
window.onerror, or the addEventListener form on the error event, gives you the message, the source file, the line and column, and on modern browsers the actual Error object with its stack. You install it as early as possible, before your game code loads, so an error during startup is caught too. From the handler you build a report with the stack and your context and send it. The error event form is preferable to the legacy onerror property because it gives you the structured error and works alongside other listeners without clobbering them.
For promises you add a listener on the unhandledrejection event, which fires when a rejected promise reaches the microtask queue with no catch attached. Its reason is whatever the promise rejected with, ideally an Error with a stack. Capturing both events through a small shared reporter means every uncaught failure, synchronous or asynchronous, funnels into one place with a consistent shape. Be mindful of cross origin script errors, which the browser sanitizes to a generic message unless the script is served with the right CORS headers and crossorigin attribute, so set those up if your game loads code from a CDN.
Source maps and minification
Production web games ship minified and bundled JavaScript, so a stack trace from the field points into a single giant line of mangled code: useless names and meaningless line numbers. Source maps are the fix. A source map records how the minified output maps back to your original source, and with it a tool or backend can deminify a production stack into real function names, files, and lines. You generate source maps as part of your build and keep them keyed to the release, applying them when a crash arrives rather than exposing them publicly if you would rather not ship your source.
The discipline mirrors native symbols: one source map per build, archived when you build, matched to the exact bundle players run. A mismatched source map deminifies to the wrong lines, which is worse than a minified stack because it misleads. Many teams upload source maps to their crash backend privately so deminification happens server side and the maps never reach players. However you do it, treat the source map as essential, because without it a production canvas game crash is a wall of single letter names that tells you nothing about where the game actually broke.
Browser and device context
Web game crashes vary along axes you do not see in a native game: the browser and its version, the rendering engine, the OS, the device, and whether hardware acceleration is on. The same code that runs perfectly in one browser can throw in another because of an API difference, a WebGL quirk, or a vendor specific behavior. So capturing the user agent, the browser and version, the OS, the screen and canvas size, and the WebGL renderer string is what lets you tell a universal bug from one confined to a single browser or GPU. Without that context, browser specific crashes look random.
Memory and performance context matter too. Mobile browsers kill tabs under memory pressure, and a canvas game pushing large textures can hit limits that desktop never sees, producing context loss or out of memory errors specific to constrained devices. Recording the device class and available memory where the browser exposes it helps you separate a logic bug from a resource limit. The browser sandbox hides some details for privacy, but the context it does expose is exactly the dimension along which web crashes cluster, so capturing all of it is the single highest value thing you can do for a web game's crash reports.
Setting it up with Bugnet
Bugnet gives a canvas game a place to send these errors with their browser context attached, so a crash arrives as a deminified stack plus the browser, version, OS, device, and WebGL renderer rather than a bare message that may have been sanitized to nothing. You hook window.onerror and unhandledrejection to forward to Bugnet, supply your source maps so production stacks deminify, and add custom fields for the current scene or asset being loaded. The in game report button lets a player who sees a freeze describe it and send the game state, which is invaluable when the canvas gives no error of its own.
Browsers are a long tail, and occurrence grouping turns that into something manageable: the same error across many browsers and versions folds into one issue with a count and a breakdown, so you see both total impact and which browsers are affected. Filtering by browser, version, or WebGL renderer tells you instantly whether a crash is everywhere or confined to one engine, which is the defining question for a web game. The occurrence count then ranks your work, so you fix the freeze hitting most players before the edge case that hits one obscure browser.
Testing across browsers
Because a canvas game's failures are so browser dependent, testing crash reporting means testing it in more than one browser. Trigger a synchronous throw and an unhandled promise rejection in each major browser and confirm both produce a deminified, contextual report. Test a cross origin script error to confirm your CORS and crossorigin setup is not sanitizing your stacks to a useless generic message. Test on a real mobile browser too, since memory limits and context loss only appear there. A report path verified in one browser tells you little about the others.
With both hooks in place, source maps archived, and context flowing, a web game stops freezing silently. Every uncaught error, wherever it happens in the browser matrix, reports itself with a readable stack and the exact browser it came from, grouped against the issues you already know. For a small team shipping a game to an unknowable spread of devices and browsers, that visibility is what makes the open web a viable platform rather than a source of unreproducible mystery freezes you can never chase down.
A canvas just freezes when it throws. Hook onerror and unhandledrejection, keep source maps, and capture the browser, or web crashes stay invisible.