HTML performance: measure a single page yourself

Before you speed a page up, find out what is slow. A few built-in browser functions time your code, catch the moments the page freezes, and show which images are the wrong size.

To measure HTML performance on a single page, you do not need an outside service. The browser has a clock built in: performance.now() times any piece of code, performance.mark() and performance.measure() store named timings, and a few more lines catch freezes and oversized images.

Try it first. Press the button, then tick Include layout and press it again.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Time a piece of code</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
  button { font: inherit; padding: 8px 14px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  label { font-size: 14px; }
  #out { margin: 12px 0 6px; font: 600 15px ui-monospace, Consolas, monospace; }
  #log { margin: 0; padding-left: 20px; font: 13px ui-monospace, Consolas, monospace; color: #4b5563; min-height: 90px; }
  #list { height: 90px; overflow: auto; margin: 10px 0 0; padding: 6px 6px 6px 28px; background: #fff; border-radius: 8px; font-size: 13px; }
</style>
</head>
<body>
<div class="bar">
  <button id="run">Build 3,000 rows</button>
  <label><input type="checkbox" id="layout"> Include layout</label>
</div>
<div id="out">Press the button.</div>
<ol id="log"></ol>
<ol id="list"></ol>

<script>
  const list = document.getElementById('list');
  const out = document.getElementById('out');
  const log = document.getElementById('log');

  document.getElementById('run').addEventListener('click', () => {
    const t0 = performance.now();          // a start time in milliseconds
    performance.mark('build-start');       // the same moment, as a named mark

    list.textContent = '';
    for (let i = 1; i <= 3000; i++) {
      const li = document.createElement('li');
      li.textContent = 'Row ' + i;
      list.append(li);
    }
    // Reading a size makes the browser do layout now, inside the timing
    const layout = document.getElementById('layout').checked;
    if (layout) list.offsetHeight;

    performance.mark('build-end');
    const m = performance.measure('build', {
      start: 'build-start', end: 'build-end', detail: { layout }
    });
    const t1 = performance.now();

    out.textContent = 'now(): ' + (t1 - t0).toFixed(1) + ' ms   measure: ' + m.duration.toFixed(1) + ' ms';

    // Every measure named "build" is kept in the timeline; show the last four
    log.innerHTML = performance.getEntriesByName('build').slice(-4)
      .map(e => '<li>' + e.duration.toFixed(1) + ' ms' + (e.detail.layout ? ' with layout' : '') + '</li>')
      .join('');
  });
</script>
</body>
</html>
Build 3,000 list items and time it two ways. Edit the code and the example reruns.

Both numbers on the first line come from the same clock. The list underneath is read back from the browser's own record of every measure named build.

performance.now(): a stopwatch for code

performance.now() returns the number of milliseconds since the page started, with a fractional part. Take one reading before the work and one after, and subtract:

const t0 = performance.now();
doTheWork();
console.log(performance.now() - t0, 'ms');

Unlike Date.now(), it never goes backwards when the computer clock changes. Browsers do round it, to make timing attacks harder. In our test on a plain page, Chromium moved in 0.1 ms steps, and Firefox and WebKit in 1 ms steps.

So a 0.3 ms function can read as 0 or 1. Time a bigger batch of work, or run it in a loop a thousand times and divide.

Marks and measures: timings with names

A mark is a named moment. A measure is the time between two marks. The browser keeps both in a timeline you can read back later, which is handy when the start and end happen in different functions.

Two marks on the page clock, and the measure between them.
Two marks on the page clock, and the measure between them.
performance.mark('build-start');
buildList();
performance.mark('build-end');
const m = performance.measure('build', 'build-start', 'build-end');
m.duration;                             // milliseconds
performance.getEntriesByName('build');  // every "build" measure so far

Leave out the end mark and the measure ends now. Pass an object instead, and you can attach extra data in detail, as the first example does to label runs with layout. All three engines we tested accepted the object form.

Entries pile up until you remove them. Call performance.clearMarks() and performance.clearMeasures() when you reset. Chrome DevTools also draws marks and measures in its Performance panel recordings.

What your timing leaves out

In the first example, building 3,000 rows took 4-5 ms in Chromium. With Include layout ticked, the same click took 31-40 ms. The extra time is layout: working out where every row goes.

Normally the browser lays out after your script finishes, before it paints. That work falls outside your marks. Reading a size, such as offsetHeight, forces layout to happen right there, inside the timing.

Neither number is wrong. The first is your script. The second is closer to what the user waits for. Know which one you are looking at.

Layout thrashing: read everything, then write

Reading a size is cheap when the layout is up to date. After a style change it is not, so the browser must lay out again before it can answer. Do that in a loop and you get layout thrashing: one full layout per element.

Mixed reads and writes force a layout per element. Batching them needs one.
Mixed reads and writes force a layout per element. Batching them needs one.

Both buttons below change the widths of 800 bars to the same values. Only the order of reads and writes differs.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Layout thrashing: read, write, read, write</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px; }
  button { font: inherit; padding: 8px 12px; border: 0; border-radius: 8px; color: #fff; cursor: pointer; }
  #mixed { background: #c2410c; }
  #batched { background: #15803d; }
  table { margin: 12px 0; border-collapse: collapse; font-size: 14px; }
  td { padding: 3px 12px 3px 0; }
  td + td { font: 600 14px ui-monospace, Consolas, monospace; }
  #bars { height: 190px; overflow: hidden; background: #fff; border-radius: 8px; padding: 6px; }
  #bars div { height: 2px; margin-bottom: 1px; background: #93c5fd; }
</style>
</head>
<body>
<div class="bar">
  <button id="mixed">Read, write, read, write</button>
  <button id="batched">Read all, then write all</button>
</div>
<table>
  <tr><td>Mixed</td><td id="tMixed">-</td></tr>
  <tr><td>Batched</td><td id="tBatched">-</td></tr>
</table>
<div id="bars"></div>

<script>
  const box = document.getElementById('bars');
  for (let i = 0; i < 800; i++) {
    const d = document.createElement('div');
    d.style.width = (20 + (i * 7) % 200) + 'px';
    box.append(d);
  }
  const bars = [...box.children];
  const next = (w) => 20 + (w + 13) % 200;  // the new width, from the old one

  // Bad: each write makes the layout stale, so the next read lays out again
  function mixed() {
    for (const bar of bars) {
      const w = bar.offsetWidth;              // read (forces layout)
      bar.style.width = next(w) + 'px';       // write (invalidates layout)
    }
  }

  // Good: one layout for all the reads, then only writes
  function batched() {
    const widths = bars.map(bar => bar.offsetWidth);
    bars.forEach((bar, i) => { bar.style.width = next(widths[i]) + 'px'; });
  }

  function time(fn, cell) {
    box.offsetWidth;                          // start from a fresh layout
    const t0 = performance.now();
    fn();
    box.offsetWidth;                          // include the final layout in both
    document.getElementById(cell).textContent = (performance.now() - t0).toFixed(1) + ' ms';
  }
  document.getElementById('mixed').addEventListener('click', () => time(mixed, 'tMixed'));
  document.getElementById('batched').addEventListener('click', () => time(batched, 'tBatched'));
</script>
</body>
</html>
Same result, different order. The table shows the time each version took on your device.

The fix is to split the loop into two passes:

const widths = bars.map(bar => bar.offsetWidth);          // all reads
bars.forEach((bar, i) => bar.style.width = next(widths[i]) + 'px');  // all writes

What we measured with this example, three runs each, in the Playwright builds of each engine on one Windows PC:

Engine Read, write, read, write Read all, then write all
Chromium 158-165 ms 1.4-1.8 ms
Firefox 25-29 ms 3 ms
WebKit 26-31 ms 2-3 ms

Your numbers will differ with the device. The gap between the two columns is the point. Reads include offsetWidth, offsetHeight, getBoundingClientRect() and size values from getComputedStyle(). Writes include style and class changes. For work spread over frames, put the writes in requestAnimationFrame.

Long tasks: when the page freezes

The main thread runs your JavaScript, handles clicks and paints frames, one thing at a time. A long task is any task over 50 ms. While it runs, the page cannot respond or redraw.

A 200 ms task blocks every frame and click behind it.
A 200 ms task blocks every frame and click behind it.

Where the browser supports it, a PerformanceObserver reports each long task:

if (PerformanceObserver.supportedEntryTypes.includes('longtask')) {
  new PerformanceObserver(list => {
    for (const e of list.getEntries()) console.log('long task', e.duration);
  }).observe({ type: 'longtask' });
}

We tested Chromium, Firefox and WebKit through Playwright. Only Chromium listed longtask and reported entries; the other two reported nothing and threw no error. That is why the check above comes first.

A check that worked in all three is the gap between animation frames. Record the timestamp in each requestAnimationFrame call. A gap far above the usual frame time means something held the thread.

Browsers pause these frames in background tabs, so a huge gap can also mean the tab was hidden.

To fix a long task, split it into chunks, move heavy math to a Web Worker, or skip rendering off-screen parts with content-visibility.

Image sizes: file pixels against screen pixels

An image can be slow without any script. The usual cause is a file far wider than the space it is shown in. The browser downloads and decodes every pixel, then throws most of them away.

The check is two numbers. img.naturalWidth is the width of the file. The width the screen needs is the displayed width times devicePixelRatio: a 100 px slot on a screen with a ratio of 3 needs 300 file pixels to look sharp.

const need = img.clientWidth * devicePixelRatio;
const ratio = img.naturalWidth / need;   // well above 1: too big; below 1: blurry

No single file fits every screen. srcset lets you list a few widths and have the browser pick one.

A finished example: a live measurement panel

This panel puts everything above on one page. It reads the load milestones from the navigation timing entry, counts long tasks where the browser reports them, tracks the slowest frame gap, and checks three images.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A live measurement panel</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px; }
  button { font: inherit; font-size: 14px; padding: 8px 12px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  #block { background: #c2410c; }
  #reset { background: #6b7280; }
  .panel { margin: 12px 0; padding: 10px 14px; border-radius: 10px; background: #111827; color: #e5e7eb; }
  .panel table { width: 100%; border-collapse: collapse; font-size: 14px; }
  .panel td { padding: 4px 0; vertical-align: top; }
  .panel td + td { text-align: right; font: 600 14px ui-monospace, Consolas, monospace; color: #86efac; }
  .imgs { display: flex; flex-wrap: wrap; gap: 12px; }
  figure { margin: 0; width: 100px; font-size: 12px; line-height: 1.35; }
  figure img { display: block; width: 100px; height: 62px; border-radius: 6px; }
  .big { color: #c2410c; font-weight: 600; }
  .small { color: #b45309; font-weight: 600; }
  .ok { color: #15803d; font-weight: 600; }
</style>
</head>
<body>
<div class="bar">
  <button id="block">Block for 200 ms</button>
  <button id="short">Run a 20 ms job</button>
  <button id="reset">Reset</button>
</div>

<div class="panel">
  <table>
    <tr><td>HTML parsed (DOMContentLoaded)</td><td id="ready">-</td></tr>
    <tr><td>Page loaded (load)</td><td id="loaded">-</td></tr>
    <tr><td>Long tasks (over 50 ms)</td><td id="long">none yet</td></tr>
    <tr><td>Slowest frame gap</td><td id="gap">-</td></tr>
    <tr><td>Last measure</td><td id="measure">-</td></tr>
    <tr><td>Device pixel ratio</td><td id="dpr">-</td></tr>
  </table>
</div>

<div class="imgs">
  <figure><img alt="Large photo"></figure>
  <figure><img alt="Medium photo"></figure>
  <figure><img alt="Tiny photo"></figure>
</div>

<script>
  const $ = (id) => document.getElementById(id);

  // 1. Page load milestones, from the navigation timing entry.
  // Read them after load: while this script runs, they are still 0.
  addEventListener('load', () => {
    const nav = performance.getEntriesByType('navigation')[0];
    if (!nav) { $('ready').textContent = $('loaded').textContent = 'no entry'; return; }
    $('ready').textContent = nav.domContentLoadedEventEnd.toFixed(0) + ' ms';
    $('loaded').textContent = nav.loadEventStart.toFixed(0) + ' ms';
  });

  // 2. Long tasks: reported by the browser, where it supports them
  let count = 0, longest = 0;
  if (PerformanceObserver.supportedEntryTypes.includes('longtask')) {
    new PerformanceObserver((list) => {
      for (const e of list.getEntries()) { count++; longest = Math.max(longest, e.duration); }
      $('long').textContent = count + ', longest ' + longest.toFixed(0) + ' ms';
    }).observe({ type: 'longtask' });
  } else {
    $('long').textContent = 'not reported here';
  }

  // 3. Frame gaps: works everywhere. A long gap means the page could not paint
  let last = 0, worst = 0;
  function frame(t) {
    if (last) worst = Math.max(worst, t - last);
    last = t;
    $('gap').textContent = worst.toFixed(0) + ' ms';
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);

  // 4. Your own code, timed with marks and a measure
  function job(name, ms) {
    performance.mark(name + '-start');
    const t = performance.now();
    while (performance.now() - t < ms) {}   // busy work that holds the main thread
    const m = performance.measure(name, name + '-start');  // no end mark = now
    $('measure').textContent = name + ' ' + m.duration.toFixed(0) + ' ms';
  }
  $('block').addEventListener('click', () => job('block', 200));
  $('short').addEventListener('click', () => job('short-job', 20));
  $('reset').addEventListener('click', () => {
    count = longest = worst = 0;
    $('long').textContent = 'none yet';
    $('measure').textContent = '-';
    performance.clearMarks(); performance.clearMeasures();
  });

  // 5. Image sizes: file pixels against the pixels the screen needs
  // (stand-in photos drawn on a canvas, so the demo needs no image files)
  function photo(w, h) {
    const c = document.createElement('canvas');
    c.width = w; c.height = h;
    const g = c.getContext('2d');
    const grad = g.createLinearGradient(0, 0, w, h);
    grad.addColorStop(0, '#60a5fa'); grad.addColorStop(1, '#f59e0b');
    g.fillStyle = grad; g.fillRect(0, 0, w, h);
    return c.toDataURL('image/jpeg', 0.85);
  }
  const imgs = [...document.querySelectorAll('figure img')];
  const sizes = [[1600, 1000], [200, 125], [60, 38]];

  function checkImage(img) {
    const need = Math.round(img.clientWidth * devicePixelRatio);
    const ratio = img.naturalWidth / need;
    const [cls, word] = ratio > 2 ? ['big', 'too big'] : ratio < 1 ? ['small', 'too small'] : ['ok', 'fits'];
    img.nextElementSibling.innerHTML = 'file ' + img.naturalWidth + ' px wide<br>' +
      'screen needs ' + need + ' px<br><span class="' + cls + '">' + word + '</span>';
  }
  imgs.forEach((img, i) => {
    img.after(document.createElement('figcaption'));
    img.addEventListener('load', () => checkImage(img));
    img.src = photo(...sizes[i]);
  });
  $('dpr').textContent = devicePixelRatio;
  addEventListener('resize', () => { imgs.forEach(checkImage); $('dpr').textContent = devicePixelRatio; });
</script>
</body>
</html>
Press "Block for 200 ms" and watch the long task, frame gap and measure rows change.
  • Load times: getEntriesByType('navigation') gives domContentLoadedEventEnd and loadEventStart. Read them in a load listener; while your script runs they are still 0.
  • Long tasks and frame gaps: the observer and the frame loop from the section above.
  • The 20 ms job: it shows up as a measure, but not as a long task, because it is under 50 ms.
  • Images: our demo calls a file too big when it has more than twice the pixels the screen needs.

To use it on your own page, copy the script and point the image check at your img elements.

When it does not work

What you see Cause Fix
Every timing is 0 or 1 ms The browser rounds the clock Time more work, or loop and divide
The timing looks tiny, but the page still hangs Layout and paint happen after your end mark Read offsetHeight before the end mark
A loop over elements is slow Reads and writes alternate Read all sizes first, then write
No long tasks are ever reported The browser does not support longtask Check supported types, and use frame gaps
DOMContentLoaded shows 0 ms Read before the event happened Read it in a load listener
No navigation entry at all Some frames have none (WebKit in a srcdoc iframe, in our test) Show a fallback instead of a number
Frame gap jumps to seconds The tab was hidden, so frames paused Reset the counter when the page is visible again
Measures keep piling up Entries stay until cleared clearMarks() and clearMeasures()

Performance numbers depend on the device, so the useful test is on someone else's phone. A screenshot shows your numbers, not theirs, and an .html attachment may open as plain code.

To send the working panel, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so each person who opens it sees the timings on their own device. If you change the code later, the same link shows the new version.

Questions people ask

How do I measure how long JavaScript takes in the browser?

Call performance.now() before and after the code and subtract. For timings you want to keep and read back, use performance.mark() at the start and end and performance.measure() between them. The measure has a duration in milliseconds.

Why use performance.now() instead of Date.now()?

performance.now() counts from the moment the page started and only moves forward, so a change to the computer clock cannot make a timing negative. Date.now() is wall-clock time in whole milliseconds and can jump when the clock is adjusted.

Why does performance.now() only give whole milliseconds?

Browsers round the value to make timing attacks harder. In our test on a plain page, Chromium moved in 0.1 ms steps and Firefox and WebKit in 1 ms steps. Time a longer stretch of work, or repeat it in a loop, to get a useful number.

What is layout thrashing?

Alternating between changing styles and reading sizes in a loop. Every read after a change forces the browser to lay out the page again on the spot. Read all the sizes first, then make all the changes, and the browser lays out once.

How can I detect long tasks in every browser?

The longtask entry type was reported only by Chromium in our test. A requestAnimationFrame loop that records the gap between frames works in Chromium, Firefox and WebKit: a gap far above the normal frame time means the main thread was blocked.

Keep reading