Skip to the content

Local experiments

Seven experiments of 2026-10-07 and 2026-10-08: listener order, the timers of an idle page, the calibrated DriftLag, workers during a block, a sustained load, and what another page sees of a hang.

We did seven experiments in this repository on 2026-10-07 and 2026-10-08. They measured facts that no document gives.

E1 measured the order of the event listeners at the target of an event. E2 measured the timers of an idle page in three engines. E3 measured the calibrated DriftLag on the same machine. E4 measured when the IndexedDB writes and the fetches of a worker complete while the main thread is blocked, also in Safari. E5 measured DriftLag during a sustained load.

E6 measured the other output APIs of a worker during a block. E7 measured what another page of the origin sees of a hang, also in Safari on iOS.

The results changed the library in five ways. An explicit hook, not the order of the listeners, records the final values before each flush. DriftLag subtracts a calibrated baseline. The probes that use setTimeout(0) start in a message task. A probe of message tasks must show an idle thread before the baseline of DriftLag increases.

E4 and E6 found a limit of the hang reports and of the hang journal in WebKit and Safari. E7 found a way around it: the peer hang watch.

E1: the order of listeners at the target

Question. When two listeners are on the target of an event, does a capture listener start before a bubble listener that the page added earlier? The DOM standard says yes: at the target, the capture listeners start in the capture pass, and the other listeners start in the bubble pass.

Method. The experiment used Vitest browser mode with the Playwright provider. In each case, it added a bubble listener first and a capture listener second. Then it dispatched one event and recorded the listener that started first.

TargetChromium 145.0.7632.6 (headless)Firefox 146WebKit (Safari 26.0)
window, custom EventBubble (the order of registration)CaptureCapture
window, PageTransitionEvent("pagehide")Bubble (the order of registration)CaptureCapture
document, custom EventCaptureCaptureCapture
document, Event with bubbles: trueCaptureCaptureCapture
Element (document.body)CaptureCaptureCapture

Meaning. The target of visibilitychange, freeze and resume is document. Thus the capture listeners of the monitors start before the listeners of an exporter in all three engines. The target of pagehide and pageshow is window. In Chromium, the listener that the page added first starts first there. Thus an exporter that added its pagehide listener before the monitors can flush before the monitors record their final values.

Change in the library. The fix does not depend on the order of the listeners. AllMonitorHandles.flush() records the values that the monitors keep until a checkpoint. At the start of each flush, otel-ts starts each onBeforeFlush listener. The setup connects the two: otel.onBeforeFlush(() => monitors.flush()).

Later, the state machine became the package page-lifecycle-tracker, with a tracker that the page shares. The otel-ts library flushes in its export phase, after the monitors recorded the transition.

Refer to setup-all-monitors.ts, LifecycleStateMachine.ts and the quick start. The browser test lifecycle.test.ts keeps a check of the document case.

E2: the timers of an idle page

Question. How long does one step of a chained setTimeout(5) take on an idle page? Which lag does the old DriftLag report on an idle page? The old DriftLag chained 20 steps of 5 ms and reported the elapsed time minus 100 ms.

Method. The experiment used the headless engines of Playwright on Windows 11. It measured each value on an idle page.

MeasurementChromium 145Firefox 146WebKit (Safari 26.0 build)
Median step of setTimeout(5)5.7 ms16 ms15 ms
Shortest step5.0 ms9 ms13 ms
Old DriftLag, idle median lag15.8 ms211 ms211 ms
setTimeout(0) from a setInterval callback (8th repeat), median5 ms16 ms16 ms
setTimeout(0) from a message task, median0 ms0 ms15 ms
requestIdleCallbackYesYesNo
performance.interactionCountYesYesYes

Meaning.

  • For a 5 ms timeout, Firefox and WebKit on Windows use the 15.6 ms timer tick of the system. Chromium asks for a 1 ms tick. This agrees with the timer rules in clocks and timers.
  • On an idle page, the old DriftLag reported more than 200 ms of lag for each 100 ms window in Firefox and WebKit, and 16 ms in Chromium. Thus the old probe measured the timer granularity, not the lag.
  • A setTimeout(0) from a nested timer gets the 4 ms clamp in Chromium, and the system tick in Firefox and WebKit. From a message task, Chromium and Firefox start it at once.
  • The WebKit build for Windows is not Safari on macOS. Safari on macOS aligns nested timers to a grid of 4 ms, and of 30 ms in Low Power Mode (WebKit DOMTimer.cpp).

Change in the library. MacrotaskLag and SchedulingFairness start each measurement in a message task (message-task.ts). A message task has the timer nesting level 0, thus the setTimeout(0) measures the queue and not the clamp. Without MessageChannel, MacrotaskLag starts the measurement in its setInterval callback and has a floor of approximately 4 ms.

E3: the calibrated DriftLag

Change. DriftLag subtracts the idle duration of one step. The baseline is the mean of the recent steps that are not longer than the median plus max(4 ms, half of the median). The lag of a window is its duration minus the number of steps multiplied by the baseline (DriftLag.ts).

Method. The experiment used the same machine and engines as E2. It recorded approximately 30 idle windows, then blocked the main thread for 300 ms.

MeasurementChromium 145Firefox 146WebKit
Baseline step5.7 ms15.5 ms15.6 ms
Idle median lag (approximately 30 windows)0.1 ms−0.3 ms−0.4 ms
Idle p90 lag3.1 ms1.3 ms1.6 ms
Lag after a 300 ms block297.4 ms282.7 ms294.6 ms

Idle lag of DriftLag before and after the calibration

Before the calibrationAfter the calibration
Chromium 145Firefox 146WebKit050100150200Median lag of an idle 100 ms window (ms) →0.1 ms-0.3 ms-0.4 ms
Show the data as a table
Median lag of an idle 100 ms window, before and after the calibration of DriftLag
EngineBefore the calibrationAfter the calibration
Chromium 14516 ms0.1 ms
Firefox 146211 ms-0.3 ms
WebKit211 ms-0.4 ms
Experiments E2 and E3, 2026-10-07, Windows 11, the headless engines of Playwright. The labels give the values after the calibration.

Meaning. The idle error went from 15.8 ms to 211 ms down to less than 0.5 ms. The resolution of the measurement is one step. A block can start immediately after a step, thus the lag of a block can be less than its duration by up to one step. That step is 16 ms in Firefox.

A first version used the median as the baseline. It gave an idle median lag of 3.3 ms in Chromium, because timer jitter is skewed to the right. Thus the baseline is a mean of the steps that are not blocks.

Check in the repository. The browser test drift-accuracy.test.ts keeps a check of the two properties. The idle median must be less than 5 ms from 0, or 10 ms in a window without focus. On macOS, the timers of an app that is not in front can be late. After a 300 ms block, the largest lag must be more than 300 ms minus the baseline minus 10 ms, and less than 340 ms. Without focus, the margin is 20 ms, because some steps of Safari are late and its baseline is longer than its usual step.

A later finding. The granularity can change while a page is open. The CDP tests found such a change in Chromium on Windows. After the page was hidden and shown again, the steps took 15.6 ms instead of 5.5 ms. The baseline of the last 100 steps followed the change slowly, and DriftLag reported windows of 160 ms of lag on an idle page. Thus DriftLag accepts a row of 10 steps that agree with each other as a new granularity (DriftLag).

E4: the input and output of workers during a main-thread block

Question. Can a worker write to IndexedDB and send a request while the main thread of its page is blocked? The hang report and the hang journal of the worker monitor depend on it.

Method. The probes are in worker-io.ts. Each probe starts an operation in a worker, and then it blocks the main thread for 2 s. It records when the operation completes, as the time after the start of the block. The experiment used the Playwright builds on Windows 11, with these probes:

  • A dedicated worker writes one record to IndexedDB.
  • A dedicated worker sends a fetch with keepalive.
  • A shared worker writes to IndexedDB and then sends a fetch each 100 ms, on its own timer. The probe counts the operations that complete during the block, with a margin of 100 ms at the start and at the end. The shared worker uses its own timer, because a message from the page can also wait.
  • A service worker (public/lag-sw-probe.js) does the same as the shared worker, on its own timer of 100 ms (2026-10-08). A service worker operates outside the page, thus it could possibly keep the hang journal where a dedicated worker cannot.

A CI job operated the same probes in Safari 26.6.2 on a GitHub macOS runner, through safaridriver (2026-10-08). The Safari column comes from the log of that job. The log gives the count of the service worker. For the other probes, the log shows that the tests passed with the expectations of WebKit.

MeasurementChromiumFirefoxWebKitSafari 26.6.2, macOS (CI log)
IndexedDB write of a dedicated worker, complete after7 ms36 ms (58 ms in a second run)2015 ms (2008 ms in a second run)After the block
fetch with keepalive of a dedicated worker, complete after19 ms14 ms2013 msAfter the block
Operations of a shared worker that completed during the block, of all its completed operations18 of 2816 of 260 of 260
Operations of a service worker that completed during the block, of all its completed operations18 of 2817 of 270 of 290 of 27

In WebKit, a message that the page posted to a shared worker immediately before the block also arrived only after the block, at 2003 ms.

Meaning. Chromium and Firefox complete the IndexedDB requests and the network requests of a worker during the block. WebKit completes them on the main thread of the page, after the block. This is also true for a shared worker and for a service worker. Safari 26.6.2 on macOS behaves the same as the WebKit build of Playwright for Windows.

In WebKit and Safari, the timer of the worker continues, thus the worker detects a hang. But it cannot send its hang report or write the hang journal until the main thread operates again. Thus there, a page that does not survive its hang leaves no record and no report. A service worker is no way out: its writes and fetches also wait for the main thread of the page.

Change in the library. The code does not change. The pages worker lag and survivorship bias give the limit.

Check in the repository. The browser test worker-io.test.ts operates the four probes in Chromium, Firefox, WebKit and Chrome, and in the CI job in Safari. In WebKit and Safari, it expects that the write and the fetch complete after the block. It also expects that the shared worker and the service worker complete no operation during the block there.

In the other browsers, it expects the write and the fetch before the end of the block. A completion 25 ms or less before the end of the block counts as a wait, because the worker and the page read different clocks. On a busy CI runner, a write of Firefox completed after 1141 ms of a block of 2000 ms: slow, but not a wait. It also expects more than 5 operations of the shared worker and of the service worker there. Thus the test fails if an engine changes its behavior.

The browser test hang-journal.test.ts skips itself where a probe finds that the IndexedDB requests of a worker wait for the main thread. The probe uses a block of 500 ms. Thus the skip comes from a measurement, not from the name of the browser. In the CI log, the test skips itself in Safari.

E5: DriftLag during a sustained load

Question. Does DriftLag report a sustained load of tasks of the same length? The baseline comes from the recent steps. Thus a load that makes all steps longer can look like a new timer granularity.

Method. The page calibrated DriftLag for 2 s. Then it kept the main thread busy for 4 s with a chain of equal tasks, and then it was idle for 1.5 s. The experiment used the Playwright builds on Windows 11 (2026-10-08).

There were two loads. In the first, each task of 20 ms starts the next task with a message. In the second, each task of 30 ms starts the next task with setTimeout(0), thus 4 ms later. The table gives the lag as a part of the time of the windows.

LoadChromium 145Firefox 146WebKit
Message tasks of 20 ms85% for 2.5 s. Then the baseline increased to 39 ms, and the lag decreased to 6%.25% for 1 s. Steps of 20 ms are in the jitter limit of steps of 15.6 ms, thus the baseline increased to 20 ms. The lag was 0 after 2 s.Approximately 0 after 0.5 s. A row of steps of 31 ms was a new granularity after 10 steps.
Timer tasks of 30 msApproximately 0 from the start. A row of steps of 34 ms was a new granularity.67% for 2 s. Then the median increased to 42 ms, and the lag decreased to 12%.66% for 2 s. Then the median increased to 41 ms, and the lag decreased to 14%.

Meaning. The calibration of E3 hides a sustained load. The lag decreased to less than 15% in each case within 4 s, and to approximately 0 in three of the six cases. The steps of a busy thread agree with each other, or the median becomes a busy step. The worker monitor measures these loads correctly, because it does not use the timers of the page.

Change in the library. A probe of message tasks must show an idle thread before the baseline increases (DriftLag). On an idle thread, a message starts at once. On a busy thread, it waits for the current task. A check is an idle probe, 3 steps, and a second idle probe. The browser tests of the change found three behaviors of the engines:

  • A probe changes the timing. A probe keeps the thread awake. In Chromium, a probed step took 0.7 ms less than an idle step, and 2.5 ms less one time. The next step took up to 3 ms more (11 ms instead of 8 ms). A first version used the probed step as the idle duration, and gave 48 ms of lag in each window of an idle page in Chrome. Thus the probed step and the next step are not recent steps.
  • Messages wait in WebKit for Windows. A chain of messages posted 84 messages in 100 ms there, and more than 13,000 in Chromium and Firefox. After an idle period, the first message waited 12 ms to 17 ms: the next timer tick of the system. Thus the probe finds no idle step there, and DriftLag operates as before the change.
  • Irregular steps in Firefox. For some seconds after a load, the steps changed between 8 ms and 16 ms. A first version lowered the confirmed value to the recent baseline of one window. Then each window had 6 ms to 10 ms of lag. Thus the confirmed value decreases only to the highest recent baseline of 3 windows.

Result. The browser test drift-load.test.ts uses a load of 3 s. The table gives the lag in the second half of the load. The rows of Windows 11 come from three runs of four browsers at the same time. The rows of GitHub runners come from the logs of one CI run:

BrowserPlatformMessage tasks of 20 msTimer tasks of 30 ms, 4 ms apart
Chromium 145Windows 11, three runs80% to 86%78% to 85%
Chrome 153Windows 11, three runs80% to 86%79% to 85%
Firefox 146Windows 11, three runs24% to 29%53% to 72%
WebKit (Playwright)Windows 11The test skips itselfThe test skips itself
Chromium 145Linux, GitHub runner87%85%
Firefox 146Linux, GitHub runner75%84%
WebKit (Playwright)Linux, GitHub runner60%79%
Safari 26.6.2macOS, GitHub runner62%77%

In WebKit on Linux and in Safari on macOS, a probe on an idle page measured 0 ms of busy time. Thus only the WebKit build for Windows makes a message wait.

After the load, the median lag of a window was between −9 ms and 8 ms, in windows of approximately 100 ms. In Firefox on Windows, an idle step already takes 15.6 ms. Thus a step that waits for a task of 20 ms gives only 4.4 ms of lag.

Check in the repository. drift-load.test.ts expects more than 10% lag in the second half of each load. After the load, it expects a median lag of less than the idle median lag before the load plus 10% of a window. A negative idle median counts as 0. It skips itself if a probe on an idle page posts fewer than 400 messages in 100 ms. The WebKit build for Windows posts 122 to 278 messages, and the other WebKit builds in CI post 580 to 920.

The unit tests of DriftLag.load.test.ts use a simulated thread with a timer queue and a message queue. They simulate the three behaviors above.

E6: the other output APIs of a worker during a block

Question. E4 found that IndexedDB and fetch of a worker wait for the main thread in WebKit. Can a worker of a blocked page write or send through another API? Each one could keep the hang journal in WebKit and Safari.

Method. The probes are in worker-io.ts. The worker first prepares the operation, for example it opens the file. Then the page posts a message that starts the operation, and it blocks its main thread for 2 s. The worker records when the operation completes.

For a WebSocket, a server in Node (commands/probe-socket.ts) records when the message arrives, with the wall clock of the machine. The page uses the same wall clock, through performance.timeOrigin.

Three operations use the origin private file system (OPFS). They are a write and a flush through a FileSystemSyncAccessHandle, a write through a FileSystemWritableFileStream, and a file lookup. The other operations are Cache.put(), an asynchronous and a synchronous XMLHttpRequest, and a WebSocket message. The Playwright builds operated on Windows 11, in two runs (2026-10-08). Safari 26.6.2 operated in the CI job on a GitHub macOS runner, in two runs.

Operation of a dedicated worker, complete afterChromium 145 and Chrome 153Firefox 146WebKit (Playwright, Windows)Safari 26.6.2, macOS
IndexedDB write42 ms44 ms2005 ms to 2036 ms2023 ms
OPFS: sync access handle write and flush6 ms to 45 ms2 ms to 40 msNo OPFS in this build2001 ms to 2002 ms
OPFS: writable stream write and close13 ms to 16 ms37 ms to 43 msNo OPFS in this build2003 ms to 2008 ms
OPFS: getDirectory() and getFileHandle()2 ms to 3 ms8 ms to 14 msNo OPFS in this build2000 ms to 2002 ms
Cache.put()29 ms to 98 ms28 ms to 75 ms2011 ms to 2023 ms2004 ms to 2009 ms
XMLHttpRequest30 ms to 103 ms2073 ms2014 ms2005 ms
Synchronous XMLHttpRequest16 ms to 315 ms2017 ms to 2065 ms2012 ms to 2176 ms2004 ms to 2058 ms
WebSocket message, at the server0 ms to 3 ms2000 ms to 2002 ms2016 ms to 2048 ms2001 ms

Meaning. In WebKit and Safari, each output API of a worker waits for the main thread of the page. Thus no API of a worker can keep the hang journal there. In the source of WebKit, the worker side of each API posts its work to the main thread of the web process. Examples are WorkerThreadableLoader, IDBConnectionProxy, WorkerFileSystemStorageConnection, CacheStorageConnection and WorkerThreadableWebSocketChannel.

In Firefox, the storage APIs of a worker complete during the block, but an XMLHttpRequest and a WebSocket message wait. Thus Firefox operates them through the main thread. The hang report of the worker uses fetch, which completes during the block in Firefox (E4). Chromium completes all of them during the block.

Change in the library. The code of the journal does not change. The peer hang watch uses another page of the origin instead of a worker (E7).

Check in the repository. worker-io.test.ts operates each probe in Chromium, Firefox, WebKit and Chrome, and in the CI job in Safari. It expects each operation after the block in WebKit and Safari. In Firefox, it expects an XMLHttpRequest and a WebSocket message after the block. It expects all others during the block. A test skips itself where the worker does not have the API.

E7: what another page of the origin sees of a hang

Question. E6 found no output API that a worker can use during a hang in WebKit. Another page of the origin has its own main thread. Can it see the hang of a page, and the end of a page that closes during its hang?

Method. The test page watches a peer page (peer-tab.test.ts). A command opens the peer page as a second top-level page: with Playwright, or as a new window with WebdriverIO in Safari. The peer page holds a Web Lock until it closes. Its main thread and a dedicated worker each send a heartbeat through a BroadcastChannel each 100 ms. It also sends a message in pagehide.

The peer page then blocks its main thread for 4 s, and the test page records the heartbeats and examines the lock. In a second case, the peer page blocks for 20 s, and the test closes it after 2 s. The test page asks for the lock of the peer page, and it records when it gets the lock. The measurements use the times of events, not of timers, because the test page can be hidden.

The Playwright builds operated on Windows 11 in three runs, and on Linux in CI. Safari 26.6.2 operated on a GitHub macOS runner, also with a block of 90 s. Safari 26.5 operated in the iOS Simulator of the same runner, with Xcode 26.6.

MeasurementChromium 145 and Chrome 153Firefox 146WebKit (Playwright)Safari 26.6.2, macOSSafari 26.5, iOS Simulator
The test page operates during the blockYesYesYesYesYes
The lock of the blocked page stays held during the blockYesYesYesYesYes
Largest gap of the heartbeats of the worker114 ms to 210 ms115 ms to 123 ms3994 ms to 4088 ms (the block)4070 ms to 4088 ms (the block)Not measured (refer to the note)
The test closes the blocked page: the lock comes free0.5 s to 8 s later14 ms to 2.8 s laterWindows: 12 ms to 52 ms later. Linux: 3 s laterAt the end of the block, also after 90 s3.4 s to 5.3 s later
pagehide of the closed pageNoYes, 13 ms after the closeWindows: no. Linux: at the end of the blockYes, at the end of the blockNo

Note on iOS. The earlier iOS result for the gaps of the heartbeats was not a measurement. Safari on iOS operates only the visible tab. While the test page is visible, the peer page sends no heartbeats, thus the gap was the full interval. The test checks that heartbeats arrived before and after the block, and it skips itself on iOS. The lock and the close results of the iOS column are real checks.

Meaning. In each engine, another page of the origin operates during the block, and the Web Lock of the blocked page stays held. Thus the other page sees the silence of the hung page, and it knows that the page still exists. The BroadcastChannel of a worker waits for the main thread in WebKit and Safari, as the other output of a worker (E6). Thus the hung page itself cannot send a message.

The engines end a page that the user closes during a hang in three ways:

  • Chromium, Safari on iOS and the WebKit build for Windows stop the page. The lock comes free without pagehide. Then another page can report the hang.
  • Firefox stops the blocked script, and Safari on macOS lets the script continue until it ends. Then the page closes normally, with pagehide. Thus the page itself can report its hang at its close.
  • The WebKit build for Linux releases the lock after 3 s, but the page continues until the end of its script.

Change in the library. The peer hang watch uses both paths. A visible page holds a Web Lock and sends a heartbeat each second. Another page reports a page whose lock comes free after 5 s or more without a heartbeat (lag.hang.source: "peer"). A page that closes at the end of a hang reports its own hang (lag.hang.source: "self").

Check in the repository. peer-tab.test.ts keeps the measurements of each engine. It expects the silence, the held lock, the BroadcastChannel of a worker, and the way in which each engine ends a closed page. peer-hang-watch.test.ts operates the library in two pages, and expects the report of the right page in each engine. It skips itself in Safari on iOS, because Safari there operates only the visible tab.

Limits of the experiments

  • The local experiments used one machine with Windows 11. The Safari columns of E4, E6 and E7 come from GitHub macOS runners. The iOS column of E7 comes from the iOS Simulator of such a runner. A simulator operates on the CPU of the Mac, thus its timers and its power behavior are not those of a phone.
  • The engines were the headless builds of Playwright, except Safari. The WebKit build for Windows is not Safari on macOS.
  • The experiments did not test Safari in Low Power Mode, or Chromium on battery power. A GitHub macOS runner refuses Low Power Mode: sudo pmset -a lowpowermode 1 gives "LowPowerMode not supported on AC Power". Thus the unit test DriftLag.power.test.ts uses the timer rule of the WebKit source instead (DriftLag). On battery, Chromium uses a timer tick of 8 ms on Windows (Bruce Dawson).
  • E3 used approximately 30 idle windows for each engine.
  • E4 used a block of 2 s. The fetch probes send their requests to the test server of Vitest on the same machine, not to a remote collector.
  • E5 used one load of 4 s for each case before the change, on Windows 11. The results of the change come from three local runs, with the four browsers at the same time on the same machine. They also come from one CI run on Linux and macOS.
  • E6 and E7 used blocks of 2 s to 90 s. A real hang can be longer. E7 closed the page through Playwright or WebDriver, not through the user interface of the browser.
  • The WebKit build for Windows makes a message wait on an idle thread (E5). In the source of WebKit, the event loop schedules each task with a timer of 0 ms (WindowEventLoop::scheduleToRun). On Windows, that timer can wait for the system timer tick (MainThreadSharedTimerWin.cpp). A newer WebKit build (Playwright 1.64) needs libGLESv2.dll from the system, and it did not start on the test machine. Thus nobody tested a newer build.
  • The browser tests of the repository operate in Chromium, Firefox, WebKit and Chrome (testing strategy). Thus they check the two cases of E1, the two properties of E3 and the behavior of E4, E6 and E7 in the four browsers. A CI job also operates them in Safari on macOS, and another in Safari in the iOS Simulator. browser-facts.test.ts checks the window case of E1.

lag: Main-thread responsiveness monitoring for browser apps, exported as OpenTelemetry metrics.

To change a page, edit its file in packages/site/content/. The writing style guide tells you how.

An AI model (Claude, from Anthropic) wrote most of the text and the code of this site and of the library, under the direction of the author. The tests and the STE linter examine them. The writing standard gives the reason for this note.