DEV Community

Cover image for The pagehide listener that never ran
Aditya Reddy
Aditya Reddy

Posted on

The pagehide listener that never ran

TL;DR: I removed a dead pagehide listener from OpenTelemetry's browser batch processors. Moving it to window would have made it run, but it would also have introduced an earlier logs flush that could miss final web-vitals records. The removal shipped in OpenTelemetry JS 3.0.0.

I found it reading the code

The browser BatchSpanProcessor and BatchLogRecordProcessor already flushed when document.visibilityState became hidden. They also registered a pagehide listener as a fallback for an old Safari bug. That listener was attached to document:

    document.addEventListener('pagehide', this._pageHideListener);
Enter fullscreen mode Exit fullscreen mode

That line comes from the removed code in my PR. The browser dispatches pagehide at window, so the document listener never received it during real navigation. The HTML page-transition event definition specifies the window dispatch.

My first version moved the listener to window. I had checked a navigation in Chromium 151, Firefox 153 and WebKit 26.5: the window listener ran in each, and the document listener didn't. Those results are recorded in the PR.

The browser review caught a problem with that change: a working listener would start flushing the logs queue earlier.

The test sent the event to the same wrong place

The old logs test created an event:

    pageHideEvent = new Event('pagehide');
Enter fullscreen mode Exit fullscreen mode

Then it dispatched that event directly on the document:

    describe('by the pagehide event', () => {
      testDocumentHide(() => {
        document.dispatchEvent(pageHideEvent);
      });
    });
Enter fullscreen mode Exit fullscreen mode

These are excerpts from the test before the change. The synthetic event reached the handler because the test chose document as its destination. It exercised the handler without checking where the browser sends pagehide.

Navigate away and keep the callbacks

For the navigation check, the small demo has three observation listeners: pagehide on both objects, plus visibilitychange on document. A link loads a second page in the same tab. Each departing-page callback writes its fields to sessionStorage; the destination displays those saved rows.

The demo start page with three observation listeners and a link to page two

Before navigation: one listener on each candidate object, plus the visibility listener. No SDK or exporter is loaded.

The fresh run on November 7, 2026, at 14:14:39 America/Chicago (-06:00), used Chrome 156.0.8078.73 in headless mode. It produced these callbacks:

Order Listener that ran currentTarget Visibility isTrusted
1 window:pagehide window visible true
2 document:visibilitychange document hidden true

The document pagehide listener produced zero callbacks.

Page two showing the two retained browser-generated lifecycle callbacks

Actual destination page from that run. The saved rows came from the departing document; this page doesn't dispatch replacement events.

In the pagehide row, event.target says document, even though the callback ran on the window listener. event.currentTarget says window. The HTML event algorithm uses a legacy target override; the DOM dispatch algorithm sets that target to the document. The target field alone doesn't tell you which listener ran.

Expanded retained output showing the event target and currentTarget fields

The retained event fields include target: document, currentTarget: window and isTrusted: true for pagehide.

This demo observes event dispatch and order. It doesn't run a telemetry exporter or measure record delivery.

Moving to window starts another flush

In the browser review, @overbalance pointed out the hard-navigation sequence: pagehide arrives before visibilitychange. A window listener would start flushing the existing logs before the application's final hidden-state records arrive. The review named final INP, CLS and LCP records as examples.

I had treated the overlap as a narrow case involving a log written by another pagehide handler. In my follow-up, I corrected that: records emitted on visibilitychange could hit it too.

The logs processor's existing _flushAll() explains why. This excerpt is from the source at the PR base:

    if (this._flushing) {
      return;
    }
    this._flushing = true;
Enter fullscreen mode Exit fullscreen mode

A concurrent call returns without waiting for the running flush. That running flush has already taken its set of records:

    let toFlush = this._finishedLogRecords;
    this._finishedLogRecords = [];
Enter fullscreen mode Exit fullscreen mode

New records enter the fresh queue, outside the snapshot the earlier flush took.

Follow one late record

In the review's scenario, the first flush is still running when the application emits its final record. Assume that record arrives before the SDK's hidden-state handler calls flush:

pagehide on window
  proposed listener starts forceFlush()
  earlier records move into the flush snapshot
  export remains in flight

visibilitychange to hidden
  application emits final record into the fresh queue
  SDK calls forceFlush()
  _flushing is still true, so the call returns

page leaves
  final record can remain outside the earlier export
Enter fullscreen mode Exit fullscreen mode

Sequence from the PR review and queue implementation. This is an explanation of the proposed change, not captured exporter output.

The second call doesn't delete the final record. It leaves that record queued while the first export handles its earlier snapshot. If the page leaves before another export opportunity, the record can miss delivery. The guard and snapshot code explain that behavior for logs; this isn't a description of the trace processor's concurrency handling.

The fresh Chrome run above has the event order discussed in the review. Other browsers and transitions need their own captures before treating that order as universal.

The merged change removes the fallback

I removed the pagehide handler, registration and cleanup from both browser processors, along with the synthetic pagehide tests. The merged diff keeps the existing visibilitychange path.

The logs handler at the final PR head still reads:

    this._visibilityChangeListener = () => {
      if (document.visibilityState === 'hidden') {
        void this.forceFlush();
      }
    };
    document.addEventListener(
      'visibilitychange',
      this._visibilityChangeListener
    );
Enter fullscreen mode Exit fullscreen mode

Source at the final PR head. The release also includes separate error handling for that flush, listed as another change in the release notes.

The old Safari fallback no longer needed reviving either. The browser review records that its underlying Safari bug was fixed in 2020, before the project's supported browser baseline.

JS 3.0.0 lists the removal for both processors under Internal. The old document listener never ran on real navigation, so removing it preserved the existing hidden-state flush and avoided introducing the earlier window flush. It didn't redesign the queue or establish a delivery guarantee for every shutdown path.

Check the record after the handler runs

When testing telemetry on navigation, put observation listeners on the actual browser dispatch objects and leave the page through a real navigation. Preserve the callback fields across that transition, including currentTarget, visibility state and isTrusted.

Then test a record emitted late, while an export is still running. Check whether its ID reaches the exporter and whether it belongs to the snapshot the flush took. If the application emits on visibilitychange, check its ordering against the SDK's own handler too.

Top comments (0)