Measuring Performance in FrontEnd using FPS
Smooth UIs run at the display’s refresh rate, usually 60 frames per second. That gives you about 16.6ms to produce each frame. Blow that budget and the page feels janky: animations stutter, scrolling hitches, input lags. Browser devtools can show you this, but sometimes you want a number inside your own app: on a perf dashboard, in a debug overlay, or logged in the field where you can’t open devtools.
A usable FPS meter is about 15 lines of plain JavaScript, no library required.
”But Chrome DevTools already shows FPS”
It does, and it works well on your own machine. Open the Command Menu (Cmd/Ctrl+Shift+P), run Show Rendering, and tick “Frame rendering stats” (older Chrome calls this the “FPS meter”). You get a live FPS overlay in the top-right of the viewport as the page runs.
The catch is that the overlay is a DevTools feature, not a web API. There’s no way to read that number from your page’s JavaScript. The browser doesn’t expose the compositor’s frame-rate meter to scripts, so you can’t grab it, store it, or send it anywhere. There’s no navigator or performance field that hands it to you. It’s there for a developer to look at with DevTools open, and nothing more.
None of that helps once you ship to real users. DevTools tells you nothing about the janky scroll on a mid-range Android phone three timezones away, owned by someone who will never open the Performance panel. To collect frame rates in the field, whether you log them, show them in a debug overlay, or send them to your analytics pipeline, you need a number your own code can compute. That’s where these 15 lines come in.
If you want to catch and attribute slow frames in production, look at the Long Animation Frames API (LoAF), available in Chrome since v123 and read via
PerformanceObserver. It reports frames that blocked too long and which script caused them. TherequestAnimationFrameapproach below gives you a continuous FPS number you control; LoAF tells you which code was responsible for the bad frames. The two work well together.
The whole idea: count frames per second
requestAnimationFrame calls you back right before each repaint. If the browser is keeping up, that’s once per frame. So if you count how many times your callback fires in a one-second window, that count is your frame rate.
let frames = 0;
let last = performance.now();
function tick(now) {
frames++;
const elapsed = now - last;
if (elapsed >= 1000) {
const fps = Math.round((frames * 1000) / elapsed);
console.log(`FPS: ${fps}`);
frames = 0;
last = now;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
That’s the whole metric. Everything below is just wiring it into a framework and drawing a nicer readout. Both demos log the value to the console every second, so open your devtools console while you play with them.
React: a useFps hook
In React the natural home for this is a custom hook. We keep the frame counter in a ref (mutating it shouldn’t trigger re-renders) and push the computed value into state once per second.
function useFps() {
const [fps, setFps] = useState(0);
const frames = useRef(0);
const last = useRef(performance.now());
useEffect(() => {
let rafId;
const tick = (now) => {
frames.current += 1;
const elapsed = now - last.current;
if (elapsed >= 1000) {
const value = Math.round((frames.current * 1000) / elapsed);
console.log(`[React FPS] ${value}`);
setFps(value);
frames.current = 0;
last.current = now;
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId); // tidy up on unmount
}, []);
return fps;
}
Here it is running. Hit Burn CPU and watch the number drop, then drag the load slider to control how hard it hammers the main thread.
Open the React demo in a new tab ↗
Angular: an FpsService with signals
In Angular, a providedIn: 'root' service fits well. It starts the loop once and exposes the value as a signal, so any component can read it and the view updates automatically, with no zone.js wiring.
@Injectable({ providedIn: 'root' })
export class FpsService {
readonly fps = signal(0);
private frames = 0;
private last = performance.now();
constructor() {
requestAnimationFrame(this.tick);
}
private tick = (now: number): void => {
this.frames++;
const elapsed = now - this.last;
if (elapsed >= 1000) {
const value = Math.round((this.frames * 1000) / elapsed);
console.log(`[Angular FPS] ${value}`);
this.fps.set(value);
this.frames = 0;
this.last = now;
}
requestAnimationFrame(this.tick);
};
}
A component then just reads fpsService.fps() in its template. Here’s the same demo in Angular:
Open the Angular demo in a new tab ↗
How the “Burn CPU” button works
To see a low frame rate, you need to starve the main thread. A naive recursive Fibonacci does the job, since it’s exponential and the work explodes as the depth grows:
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
If you called fib(40) in a tight loop, the tab would freeze solid and the FPS counter couldn’t even update. So the demos run it in setTimeout(..., 0) chunks: compute one fib(depth), yield to the browser, then queue the next one. The main thread stays mostly busy and leaves requestAnimationFrame only scraps of time, so the meter slumps to 15-25 fps instead of flatlining, and recovers the moment you stop.
let burning = false;
function loop() {
if (!burning) return;
fib(depth); // hog the main thread for a while
setTimeout(loop, 0); // yield so the page can paint, then burn again
}
Shipping it to production
Once you have the number, emit it as a metric. Send the per-second value, or a rolling average, to whatever you already use for analytics or RUM, the same way you report any other client-side measurement.
You rarely need this from every user. The loop has a small ongoing cost, and a sample is enough to spot trends, so put it behind a feature flag and enable it for a small percentage of traffic. Start at 1% or 5%, watch the data, and ramp up or down as it suits your circumstances.
Tracking trends and catching performance regressions
Average the per-second readings across a whole user journey and you get one number for that flow: landing on a page, running through a few interactions, reduced to an average FPS. Record that per release and you can tell whether a build made things smoother or worse, rather than waiting for someone to notice lag.
The same number works in CI. Drive the journey in an end-to-end test with Cypress or Playwright, read the FPS the meter already computes (capture the console logs, or stash the value on window and read it back), and record or assert on it. Tracking it on every build turns a performance regression into a trend line or a failing check, the way you already watch bundle size or coverage.
Caveats
- rAF is capped at the refresh rate. On a 60Hz screen you won’t see above ~60; on a 120Hz or 144Hz display the ceiling is higher. The meter measures cadence, not headroom: a steady 60 means you’re keeping pace, not that you have time to spare. It tells you when frames start getting missed, but not how close to the edge you were while still hitting the cap. For that, time the actual work per frame (the Long Animation Frames API mentioned above is one way).
- Background tabs get throttled. Browsers throttle
requestAnimationFramein hidden tabs, so a backgrounded meter reads low. That’s expected, not real jank. - This measures main-thread frame cadence, not GPU time or paint time. It’s a cheap, useful signal for “is my JavaScript blocking frames?”, which is the most common cause of jank, but it won’t catch a GPU-bound compositor stall.
For most apps, that 15-line loop is enough to turn “it feels laggy” into a number you can watch and log.