DEV Community

Syed Salman | A2Z Tools
Syed Salman | A2Z Tools

Posted on Fully Autonomous

Measuring a monitor's refresh rate with requestAnimationFrame (and why the average lies)

"Is my 144 Hz monitor actually running at 144?" is a question people search for every day, and the browser can answer it reasonably well. requestAnimationFrame fires once per frame the browser presents, with a high-resolution timestamp. The gap between two callbacks is the frame time, and 1000 / frameTime is the refresh rate.

That's the whole idea. Making it give a trustworthy number took a few more decisions, which I made while building the refresh rate test on A2Z Tools.

The basic loop

const WINDOW = 120;      // frames the rate is measured over
const AWAY_MS = 500;     // a gap this long is the tab being away, not a frame
let intervals = [], last = 0;

function frame(now) {
  requestAnimationFrame(frame);
  if (last) {
    const dt = now - last;
    if (dt > 0 && dt < AWAY_MS) {
      intervals.push(dt);
      if (intervals.length > WINDOW) intervals.shift();
    }
  }
  last = now;
}
requestAnimationFrame(frame);
Enter fullscreen mode Exit fullscreen mode

A rolling window of 120 frames means the reading follows the display live instead of averaging over everything since page load.

Use the median, not the mean

One slow frame, from garbage collection or another tab waking up, drags an average well below the true rate. On a 60 Hz screen a single 50 ms hiccup in 120 frames pulls the mean down to about 59 Hz. The median ignores it:

function medianOf(values) {
  const s = values.slice().sort((a, b) => a - b);
  const m = s.length >> 1;
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}

const hz = 1000 / medianOf(intervals);
Enter fullscreen mode Exit fullscreen mode

A refresh rate should be one of a handful of fixed numbers, so an outlier-resistant statistic is the right tool. I use the same idea for jitter: the median absolute deviation from the median frame time, not the standard deviation.

Snap to a known rate, but only when it's close

A 144 Hz panel measures as 143.9 or 144.1. Showing "143.9 Hz" makes people think something is wrong. So the big number snaps to a common rate, but only within a tolerance, and the raw value is always shown underneath:

const COMMON = [30, 48, 50, 60, 75, 90, 100, 120, 144, 165, 180, 240, 360];

function snap(rate) {
  for (const c of COMMON) {
    // 1.5 Hz is tight at 60 but too tight at 360, where timing noise
    // is a bigger share of a 2.8 ms frame
    if (Math.abs(c - rate) <= Math.max(1.5, c * 0.025)) return c;
  }
  return null;   // unusual panel: report it as itself
}
Enter fullscreen mode Exit fullscreen mode

The tolerance scales with the rate. A fixed ±1.5 Hz works at 60 Hz, where a frame is 16.7 ms, but at 360 Hz a frame is only 2.8 ms and normal timer noise moves the reading more than that.

Don't call the first frames "dropped"

Counting a frame as dropped when it takes about twice the median (dt >= median * 1.8) is a useful signal. But the first frames after page load are the page itself settling: fonts, layout, scripts. Counting those blames the monitor for the page's own startup, so nothing is reported until 30 frames are in.

Background tabs and monitor changes

Browsers throttle or stop requestAnimationFrame in hidden tabs. If you keep measuring, you get a nonsense reading when the user comes back. So on visibilitychange the meter pauses, and when the tab returns it throws the old samples away and starts again.

The same goes for dragging the window to another monitor, which is common with a 60 Hz laptop screen next to a 144 Hz external display. I keep a cheap signature of the current screen and reset when it changes:

const signature = () =>
  [screen.width, screen.height, devicePixelRatio, screen.availLeft || 0, screen.availTop || 0].join('|');
Enter fullscreen mode Exit fullscreen mode

Be honest about what it measures

This is the rate the browser presents frames at. That's usually the monitor's rate, but not always:

  • Power-saving modes (Chrome's Energy Saver, Windows battery saver, Low Power Mode on iPhone) often cap it at 30 Hz.
  • An older HDMI cable or port can cap a high-refresh monitor at 60 Hz, and so can the OS display settings.
  • A variable-refresh (FreeSync/G-Sync) display can slow down when little on screen moves.

So the page reacts to the result. At around 30 Hz it suggests checking power saving. At 60 Hz it suggests checking the OS setting, the cable and the browser. Above 60 it confirms the high refresh, with the VRR caveat. A tool that shows "60" and leaves you to guess the reason isn't very useful.


Disclosure: I'm the founder of A2Z Tools, where this tool lives. It runs entirely in the browser. This post was drafted with AI assistance; the code is adapted from the production tool.

Top comments (0)