The problem. Engagement (dwell) time for a page view is only known after the user
leaves it — but the moment they leave is exactly when the page is being torn down. Firing a final
request on unload/pagehide is unreliable across modern browsers, so the last
bit of engagement (and the entire last page view of a session) is easily lost.
The approach: a heartbeat to a delayed-events endpoint. While a page is open, the SDK sends a small heartbeat every 1s. Each heartbeat carries two kinds of events:
instant_events — ingested immediately, exactly once.
[Amplitude] Page Viewed is sent here on the first heartbeat.events (delayed) — not ingested right away.
[Amplitude] Page Viewed Completed, carrying the running engaged-dwell time, is sent on
every heartbeat.The delayed service holds, it doesn't ingest. Each heartbeat upserts a row keyed by a
per-page-view id. The row stores the latest delayed events and resets a 10s timeout.
As long as heartbeats keep arriving, the row keeps getting replaced and the timeout keeps getting pushed
out — so nothing is ingested while the user is still on the page.
Ingestion happens on timeout. When the user navigates away or closes the tab, the heartbeats
stop. After 10s of silence the row's timeout lapses and the delayed service flushes its last snapshot —
the final Page Viewed Completed with the total dwell time — into ingestion. No client request
is needed at exit, which is what makes it reliable and what lets us capture the final page view of
a session.
delayed to watch the 1s
heartbeat requests to /httpapi/delayed.instant
(page views), http-api (regular events), delayed-expired (delayed events
flushed on timeout).localStorage; a timer plays the
role of the server-side timeout reaper. The heartbeat network requests are real, so the 1s cadence
is observable in DevTools. Use Reset POC state if a stale row lingers from an earlier visit.
| id | timeout | events |
|---|
| # | source | event |
|---|
Click an event in either table to see the raw HTTP request body it arrived in.