Long Animation Frames for game devs
Your game hitches every couple of seconds. You open the Performance panel, press record, wait, play, stop, and scroll through a flame chart looking for the one frame that felt wrong. If it didn't happen while you were recording, you start again.
Chromium browsers can already answer most of that question for you, all the time, with no recording: the Long Animation Frames API (LoAF). Most game developers have never heard of it. This post covers what it tells you, the two ways it falls short for games, and how myaku fills the gaps. We measured it on a real Phaser game.
What Long Animation Frames is
Long Animation Frames shipped in Chrome 123, in March 2024. It's in every Chromium browser (Chrome, Edge, Opera, Brave), and the W3C spec is still a draft. Firefox and Safari don't have it yet.
It replaced the older Long Tasks API. Long Tasks only said "the main thread was blocked for a while". LoAF tells you which frame was slow, how its time divided between script, style and layout, and which scripts ran, with their source URL, function name and position.
You subscribe once and it keeps reporting for the life of the page:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) console.log(entry.toJSON());
}).observe({ type: "long-animation-frame", buffered: true });What an entry looks like
To get real entries, we took Runner from Phaser by Example, a small Phaser 3 endless runner, and added two deliberately slow functions to its scene's update:
rebuildSkyline(): 70 ms of work every 120 framessortClouds(): 30 ms of work every 200 frames
Then we recorded 8 seconds of play in Chrome with npx myaku record. One of the entries the browser produced, trimmed:
{
"entryType": "long-animation-frame",
"startTime": 6280.6,
"duration": 87.4,
"renderStart": 6296.9,
"styleAndLayoutStart": 6367.9,
"blockingDuration": 21.1,
"scripts": [
{
"invoker": "FrameRequestCallback",
"invokerType": "user-callback",
"duration": 71,
"forcedStyleAndLayoutDuration": 0,
"sourceURL": "http://localhost:60303/__myaku__/profiler.js",
"sourceFunctionName": "",
"sourceCharPosition": 10709
}
]
}Reading it:
duration: the whole frame, 87 ms. A 60 Hz game has 16.7 ms.renderStarttostyleAndLayoutStart: the rendering phase, which is whererequestAnimationFramecallbacks run. Here that's 71 ms, almost all of it.scripts: what ran.FrameRequestCallbackmeans arequestAnimationFramecallback. Other invokers you'll see are event handlers (IMG#player.onload,DOMWindow.onmessage), timers and promise resolutions.sourceURL,sourceFunctionName,sourceCharPosition: where that script is. For a click handler or a loader callback, this is your function, and you can go straight to it.forcedStyleAndLayoutDuration: layout your script forced by reading layout (offsetWidth,getBoundingClientRect) after changing styles. It's rare in canvas games but common in DOM-based UI.
That's already more than a frame counter gives you. For games, though, there are two catches.
Catch one: 50 milliseconds
LoAF only reports frames that take more than 50 ms. That's a sensible threshold for web pages. For a game it's three frames.
Our sortClouds() frames took 30 ms each, nearly two frames' worth, and players feel that as a stutter. The browser produced no entry for any of them. In 8 seconds of play, LoAF reported 3 frames, and the game actually hitched 7 times.
So LoAF can explain your worst frames, but it can't find all your bad ones. You still need to measure frames yourself.
Catch two: everything is one callback
Look at the script entry again. In a game, nearly all the work happens inside a single requestAnimationFrame callback: the engine's loop. LoAF stops at that boundary, so every slow frame says, more or less, "the game loop took 71 ms".
In our recording, the entry points at myaku itself, because myaku wraps requestAnimationFrame to time every frame. Without myaku, it would point at whatever the engine handed to requestAnimationFrame. In Phaser, that's its game loop. Either way it's true, and it doesn't tell you much.
How myaku fills the gaps
myaku is a frame profiler for web games. It runs all the time, keeps the last ten seconds of frames, and explains each slow one. It uses LoAF where it helps and its own measurements where LoAF can't see.
It measures every frame. It times each requestAnimationFrame callback, so a frame counts as slow at 1.5× the display's interval (25 ms at 60 Hz), not at 50 ms.
Measuring this properly turned up a gap in myaku itself while we wrote this post. After a frame whose callback ran 30 ms, Chrome often delivers the next frame with a timestamp right back on the regular 16.7 ms grid, so the gap between timestamps looks normal. myaku now counts a frame as lasting at least as long as its own code ran. That's what caught all 7 hitches above.
It looks inside the callback. When LoAF blames myaku's wrapper, myaku replaces that with the game's own callback for that frame and its source line. Then engine spans split the callback further: update, update:<scene key> and render for Phaser, update and render for PixiJS, KAPLAY and Excalibur, and render for three.js. All of these are found automatically.
Here's what that looks like for one of the 70 ms frames:
Frame 136 took 71 ms (budget 25 ms). 71 ms was script:
stepin node_modules/.vite/deps/phaser.js:22944. Spans: update:game 70 ms, update 70 ms, render 0.1 ms.
And for a 30 ms frame, which LoAF never reported:
Frame 316 took 30 ms (budget 25 ms). The game's frame code ran 30 ms. Spans: update:game 30 ms, update 30 ms, render 0.1 ms.
To be clear about the limit: myaku doesn't name rebuildSkyline(). Neither LoAF nor myaku can see which of your functions ran inside Phaser's loop. What you get is the frame, how long it took, and that the game scene's update spent 70 of its 71 ms, not drawing. For the last step, wrap the suspects in your own spans:
import { myaku } from "myaku";
const profiler = myaku();
update() {
profiler.span("skyline", () => this.rebuildSkyline());
profiler.span("clouds", () => this.sortClouds());
}Then the verdict names skyline.
It checks more than script. A frame can be slow for reasons no script entry shows. myaku also counts texture uploads and shader compiles on the WebGL context, watches the heap for garbage collection, and notices when the browser throttled a hidden or covered window. Each verdict has a cause (script, layout, upload, compile, gc, throttled, hidden or unknown) and a confidence. When the evidence is thin it says unknown rather than guessing.
Without Long Animation Frames
In Firefox and Safari, myaku still measures every frame, your spans and the engine spans, and the WebGL and heap evidence. What's missing is the script attribution, so script verdicts rely on frame code time and spans alone. myaku record, which drives the game for you, needs Chrome or Edge.
Try it
Add one line to the top of your entry file, and a frame graph appears in the corner. Click a red spike to read its verdict:
import "myaku/auto";Or record a build without changing it:
npx myaku record dist/Get started with myaku → · Why does my Phaser game stutter? · Why does my PixiJS game stutter?