# When the page leaves before the timer fires

By ayobamih

Canonical: https://aiworkaccountability.com/writing/when-a-timer-outlives-the-page/
Published: 2026-10-25
Updated: 2026-10-25

**Ayobami John Haastrup · Engineering case study · 25 October 2026**

A visitor reads a page, scrolls, then closes the tab. The analytics SDK has a final performance measurement to report. It puts that measurement in a buffer and schedules a callback for five seconds later. The callback may never run: the page that owns it is leaving.

That was the failure reported in [PostHog issue #5318](https://github.com/PostHog/posthog-js/issues/5318). My contribution, [PR #5322](https://github.com/PostHog/posthog-js/pull/5322), added a lifecycle-triggered drain and regression tests. It was merged on 20 October 2026. The interesting engineering is the contract between observation, buffering and delivery—and the evidence needed to show that the contract changed.

## A measurement can exist without becoming an event

The issue reporter described missing final LCP, CLS and INP values on visits that ended without sufficient activity afterwards. They provided a reproduction against posthog-js 1.438.12: compare reading and closing a page with clicking and waiting before closing it. That production observation belongs to the reporter; it was not a production measurement performed for this contribution.

The implementation explained a plausible cause. `WebVitalsAutocapture` accumulated metrics for a navigation. It normally flushed on a five-second timer, when the navigation changed, or when the buffer reached the configured metric count. A metric finalised at hide could therefore enter the buffer just as the browser stopped giving the page further execution time. [Before-fix source](https://github.com/PostHog/posthog-js/blob/8cd9ff4fb94df6002531a2e0618d10f505cba55b/packages/browser/src/extensions/web-vitals/index.ts).

| Stage | What had happened | What was still unproven |
| --- | --- | --- |
| Metric callback | A measurement reached the extension | An analytics event had been captured |
| Buffer append | The measurement was held in memory | The scheduled drain would execute |
| Capture call | The extension handed an event to the SDK | The request would leave before shutdown |
| Transport attempt | The SDK selected a delivery path | The server had received and stored the event |

The distinctions explain why reducing the timer is an incomplete remedy. A shorter delay still depends on a future callback. At the page-lifecycle boundary, the requirement is to perform the available hand-off while execution remains possible.

The issue also raises a measurement-quality concern: if successful reporting depends on interaction or remaining on the page, the recorded sample may differ systematically from all visits. That is a consequence suggested by the failure mechanism and the reporter's observations. This contribution did not quantify population bias or measure a dashboard improvement after release.

## Two buffers, two chances to miss the deadline

Flushing the extension's buffer was necessary, but the normal capture path could still enqueue the result. The SDK had its own request queue and an unload handler. That handler was registered before the extension initialised, so merely adding another late `pagehide` callback and using ordinary queued capture could place work behind a queue drain that had already happened. The relevant ordering and queue drain are visible in the [pinned SDK core](https://github.com/PostHog/posthog-js/blob/0f97a93147b85765ecb3f3f31b4586a4916f4cbd/packages/browser/src/posthog-core.ts#L1087-L1095) and its [unload handler](https://github.com/PostHog/posthog-js/blob/0f97a93147b85765ecb3f3f31b4586a4916f4cbd/packages/browser/src/posthog-core.ts#L1442-L1468).

The fix therefore changed both **when the extension drained** and **how that particular capture was dispatched**. Hidden-page captures specify `transport: 'sendBeacon'` and `send_instantly: true`; ordinary timer and navigation flushes keep their existing path. This reuses the SDK's capture pipeline rather than constructing a second analytics client. [Merged drain implementation](https://github.com/PostHog/posthog-js/blob/5beb4146c7cd05b7b00908459d1151fd723abe4f/packages/browser/src/extensions/web-vitals/index.ts#L246-L270).

There is a useful detail in the [capture implementation](https://github.com/PostHog/posthog-js/blob/0f97a93147b85765ecb3f3f31b4586a4916f4cbd/packages/browser/src/posthog-core.ts#L2025-L2073): an options object without a batch key already bypasses the normal batching condition. It would be misleading to credit `send_instantly` alone for that effect. The flag also feeds the existing preference for synchronous compression. The point is that the complete options contract selects an immediate delivery path, not that one boolean magically guarantees delivery.

Selecting a beacon still is not a server receipt. The pinned [transport implementation](https://github.com/PostHog/posthog-js/blob/0f97a93147b85765ecb3f3f31b4586a4916f4cbd/packages/browser/src/request.ts#L517-L554) explicitly handles browser rejection and fallback. This patch removes one avoidable scheduling dependency; it cannot promise that every final event reaches storage.

## Ordering and duplicate prevention

The extension registers its new listeners after registering the web-vitals observers. The intended order is: final metric callbacks append to the buffer, then the hidden-page listener drains it. A `visibilitychange` only triggers this path when the document is hidden; `pagehide` provides another lifecycle trigger. [Observer and listener registration](https://github.com/PostHog/posthog-js/blob/5beb4146c7cd05b7b00908459d1151fd723abe4f/packages/browser/src/extensions/web-vitals/index.ts#L417-L441).

Two triggers introduce an obvious regression risk: the same buffer could be emitted twice. The drain uses three operations already suited to that problem: cancel the pending timer, return immediately when the buffer is empty, and clear the buffer after capture. In simplified pseudocode—not a replacement for the source—the contract is:

```text
drain(finalLifecycleEvent):
    cancel scheduled drain
    if no buffered measurements: return
    capture buffered measurements using the appropriate path
    reset buffer
```

This gives the next lifecycle callback an empty buffer to inspect. It also prevents the old timer from sending the same buffered event later. New measurements arriving afterwards are a different case and can legitimately form another event. The contribution did not introduce a global exactly-once delivery protocol.

## Tests that target the boundary

The two added test specifications sit inside four existing metric-configuration cases. They yield eight additional parameterised checks, rather than eight independently designed scenarios. The tests manually invoke the mocked CLS callback, dispatch lifecycle events and observe `posthog.capture`. [Configuration matrix](https://github.com/PostHog/posthog-js/blob/0f97a93147b85765ecb3f3f31b4586a4916f4cbd/packages/browser/src/__tests__/extensions/web-vitals.test.ts#L102-L169); [new regression tests](https://github.com/PostHog/posthog-js/blob/0f97a93147b85765ecb3f3f31b4586a4916f4cbd/packages/browser/src/__tests__/extensions/web-vitals.test.ts#L262-L305).

| Probe | Assertion that matters |
| --- | --- |
| One buffered metric, then `pagehide` | Capture happens before advancing the timer, with the immediate beacon options |
| Advance the old timer after that hide | The matching web-vitals capture count remains one |
| Dispatch visibility while still visible | No premature lifecycle capture occurs |
| Change to hidden, then dispatch `pagehide` | The metric is captured and the second trigger does not duplicate it |

The tests count matching `$web_vitals` events rather than every SDK capture, because unrelated lifecycle events can occur in the same fixture. They also assert the expected metric value and transport options. That makes the regression more specific than checking that a callback was invoked.

The PR records **eight failures before the implementation change and 109/109 web-vitals tests passing afterwards**. Those are the contributor's recorded local results. This article does not present them as a fresh rerun. The added tests verify the extension-to-capture boundary; their mocked metric producers and synthetic DOM events do not independently establish observer ordering during a real tab shutdown or server-side receipt.

## What review contributed

The final change was collaborative. My implementation and tests were followed by a [maintainer-authored commit](https://github.com/PostHog/posthog-js/commit/0f97a93147b85765ecb3f3f31b4586a4916f4cbd) replacing direct DOM listener calls with PostHog's existing `addEventListener` helper and visibility-event constant. The helper centralises passive-listener defaults and the repository's lint rule. [Helper implementation](https://github.com/PostHog/posthog-js/blob/0f97a93147b85765ecb3f3f31b4586a4916f4cbd/packages/browser-common/src/utils/general-utils.ts#L192-L206).

That revision is a practical lesson in contributing at the repository's own level of abstraction. Correct browser behaviour is one requirement; using the project's established mechanism is another. That maintainer improvement is part of the accepted result and is credited separately from my contribution.

The public record contains one [maintainer approval](https://github.com/PostHog/posthog-js/pull/5322#pullrequestreview-5587984132) on the final head and no inline review comments returned by the API. There is no evidence for inventing a longer design debate around the change.

## What the merge establishes

A fresh GitHub read on 25 October found **65 check runs on the final head: 57 successful and eight skipped**. Successful jobs included [Unit tests](https://github.com/PostHog/posthog-js/actions/runs/39449274860/job/118724780644), [Functional tests](https://github.com/PostHog/posthog-js/actions/runs/39449274860/job/118724781183), and the [Chromium, Firefox and Safari integration workflow](https://github.com/PostHog/posthog-js/actions/runs/39449274819). These are retrieved CI outcomes, not tests rerun while writing this article. Passing a browser suite does not by itself prove that it exercised this exact real-world shutdown scenario.

The PR's earlier note that upstream workflows needed approval and its local typechecking limitations remain part of the history. The later successful CI supplies additional repository-level evidence; it does not retroactively turn an incomplete local check into a completed one.

This is a merged engineering contribution with inspectable implementation, regression coverage, review and CI. A broader empirical paper would need a controlled browser/lifecycle matrix, server-observed receipt rates, explicit loss and duplication measures, and analysis of whether the fix changes sampling bias. Those experiments have not been reported here.

The transferable lesson is to audit every deferred step between observing a result and recording it. In this case, the performance metric was already available. Reliability depended on handing it across the page-lifecycle boundary without asking a disappearing page to wake up five seconds later.

*AI assistance: the contribution's public disclosure identifies AyobamiH as the human directing contributor and credits ChatGPT with source review and test assistance. ChatGPT/Codex also assisted with this retrospective's research and drafting. The original issue reporter and maintainer changes are credited separately above.*


The [curated evidence record](/evidence/open-source/posthog-5322.json) preserves the merge identities, source links, review references and CI observations used here.


Author-published engineering case study by Ayobami John Haastrup (ayobamih). AI assistance and verification boundaries are disclosed in the article. Not peer-reviewed.
