feat(analytics-browser): enable fetch keepalive by default to survive page navigation#1781
Conversation
… page navigation The default FetchTransport did not set `keepalive`, so when a page redirects or navigates away the browser cancels the in-flight upload and events are lost. Enable `keepalive` by default, gated on body size. The Fetch spec caps the combined size of all in-flight keepalive requests in a document at 64 KiB and returns a network error past that. Because that budget is shared (analytics + session-replay + the customer's own code), gate keepalive at half the budget (32 KB). Larger bodies fall back to a plain fetch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
size-limit report 📦
|
…omments Address review feedback on #1781: - Add a test that keepalive is disabled when the compressed ArrayBuffer body exceeds the budget (the production gzip path), and assert keepalive on the existing gzip test. - Note the ASCII assumption in the over-budget test. - Shorten the MAX_KEEPALIVE_BYTES comment and drop the redundant inline comment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er internals Address review round 2 (S1): mock compressToGzipArrayBuffer (and isCompressionStreamAvailable) directly instead of intercepting Response, so the test no longer depends on how the compression helper is implemented internally. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed review round 2:
Not changing, with reasoning:
|
There was a problem hiding this comment.
Pull request overview
This PR updates the @amplitude/analytics-browser Fetch transport to opt requests into fetch(..., { keepalive: true }) by default so uploads are more likely to complete during page navigation/redirects, while avoiding browser keepalive budget rejections by gating keepalive based on the actual request body size.
Changes:
- Added a
MAX_KEEPALIVE_BYTESbudget constant (32 KiB) and setkeepalivebased on the measured outgoing body size (string UTF-8 bytes or compressedArrayBuffer.byteLength). - Updated and added Jest tests to assert keepalive behavior for small, oversized, and oversized-after-gzip payloads.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| packages/analytics-browser/src/transports/fetch.ts | Enables keepalive by default with a body-size gate to avoid Fetch keepalive budget network errors. |
| packages/analytics-browser/test/transport/fetch.test.ts | Adds/updates transport tests to verify keepalive is enabled/disabled appropriately (including compressed-body cases). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@bug Bot |
|
bugbot run |
… bodies Address Copilot review: extract isWithinKeepaliveBudget() and short-circuit on string length before allocating a Blob. UTF-8 byte length is always >= the JS string length, so a string longer than the budget is already over it and needs no exact measurement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit ecea5e8. Configure here.
…e estimate Drop the Blob allocation entirely: use string length as a cheap byte proxy for the keepalive gate, mirroring the existing gzip threshold logic (bodyString.length >= MIN_GZIP_UPLOAD_BODY_SIZE_BYTES). The 32 KB cap (half the 64 KiB budget) leaves headroom for UTF-16->UTF-8 expansion. The compressed path keeps its exact byteLength. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 0410353. Configure here.
…MAX_BODY_SIZE_BYTES Address Copilot review: the old name mirrored session-replay's MAX_KEEPALIVE_BYTES (which holds the full 64 KiB budget) and misleadingly implied this was the budget rather than a conservative per-request body cap at half the budget. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit c18000f. Configure here.
…mments Use 64 KiB / 4 = 16 KB as the keepalive body-size cap: since .length gates on UTF-16 code units (each at most 3 UTF-8 bytes), 16 KB keeps even fully multi-byte payloads within the shared 64 KiB budget. Remove explanatory comments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only the comments added in this PR should have been removed; restore the original "Temporary browser-specific fetch transport" note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| headers, | ||
| body, | ||
| method: 'POST', | ||
| keepalive: bodySize <= KEEPALIVE_MAX_BODY_SIZE_BYTES, |
There was a problem hiding this comment.
My concern about this is that we're potentially competing with the combined keep-alive budget of other requests so we should only use this when necessary.
I think we should add another condition to this:
let isPageExiting = false;
function beforeUnloadHandler() {
isPageExiting = true;
}
globalScope?.addEventListener('beforeunload', beforeUnloadHandler);
//...
keepalive: bodySize <= KEEPALIVE_MAX_BODY_SIZE_BYTES && isPageExitingThere was a problem hiding this comment.
The request is canceled if the user closes the tab at any point between when the request is initiated and the tab is closed. Listening to a page exit flag isn't helpful here.
The risk is small here because the payload size is less than 1 KB. See the screenshot in the PR description. The size is released as long as the request succeeds.
There was a problem hiding this comment.
I'm going to need to research how keepalive works better. It sounds like there's some tradeoffs between using keepalive and not using keepalive meaning there's a risk of nasty surprises. I feel like this implementation does make sense and I'll probably approve it, but I need to know what it is we're shipping first.
There was a problem hiding this comment.
No problem, take your time 🙏
Summary
Linear: SDKW-14
This PR adds a new capability to the browser SDK: the default
fetchtransport now sends uploads withkeepalive: true, so in-flight event requests can complete even when the page is navigating away (redirects, link clicks, tab close).Previously, delivering events at navigation time meant opting into
transport: 'beacon'(navigator.sendBeacon). That works, butsendBeaconis limited: POST-only, no custom headers, no gzip (Content-Encoding), no access to the server response, and a hard 64 KiB cap.fetchwithkeepalivegives the same "survives navigation" behavior on the default transport while keeping everything we already rely on — gzip compression, custom headers, response handling, and the existing retry path. In short, customers no longer have to trade away those features to get navigation-time delivery.What changed
packages/analytics-browser/src/transports/fetch.tsKEEPALIVE_MAX_BODY_SIZE_BYTES = 16 * 1024.keepalivewhen the outgoing body is within the cap. Size is measured withArrayBuffer.byteLengthfor the gzip-compressed body, and the cheap JSON string length as a rough estimate for the uncompressed body (no per-requestBlob/TextEncoderallocation).Why a 16 KB cap?
keepalivecomes with a hard, browser-enforced budget, and opting an oversized request into it is worse than leaving it off — the browser rejects the request outright instead of sending it. The cap is a graceful fallback: bodies within it get navigation survival; bodies above it send as a normalfetch(no size limit, just no survival).The limit is normative in the Fetch spec. WHATWG Fetch Standard, "HTTP-network-or-cache fetch":
Source: https://fetch.spec.whatwg.org/#http-network-or-cache-fetch
Three things drive the 16 KB choice:
It's a network error, not a silent downgrade. A plain
fetch()has no body-size limit; the moment you addkeepalive: true, a body over budget makes the browser reject the request entirely. So we only opt a request into keepalive when its body fits.The 64 KiB budget is shared, not per-request.
inflightKeepaliveBytesis the combined total of all in-flight keepalive requests in the document — our analytics flushes, session-replay uploads (which already use keepalive), and any keepalive/sendBeacontraffic from the customer's own code. Headers count toward it too. A cap well below the full budget leaves headroom and reduces contention-induced rejections.We gate on string length, which can undercount UTF-8 bytes. To avoid allocating a
Blob/TextEncoderon every send, the uncompressed path usesbody.length(UTF-16 code units) as a rough proxy for byte size — mirroring the existing gzip threshold (bodyString.length >= MIN_GZIP_UPLOAD_BODY_SIZE_BYTES). A code unit expands to at most 3 UTF-8 bytes (a surrogate pair is 2 units → 4 bytes, i.e. 2/unit), so using64 KiB / 4 = 16 KBconservatively keeps even a fully multi-byte payload within the real budget.For reference, Sentry adopted keepalive-by-default and added in-flight queue accounting after early issues with >64 KB requests (sentry-javascript#7553, #2548), and
session-replay-browseralready uses keepalive in this repo (commit fa5f84a).Real-world payload sizes
In practice, event upload payloads are tiny relative to the 16 KB cap because the body is gzip-compressed. Observed request payload size is ~0.2 KB whether the batch contains a few events (e.g. 3–4) or many (e.g. 30) — so virtually all real traffic stays well under the cap and benefits from keepalive.
Behavior & blast radius
fetchwith no size limit, no navigation survival.catch→handleOtherResponse→ re-schedule), bounded byflushMaxRetries— so the outcome is delay, not loss.keepaliveis Baseline Newly available (Firefox 133, Nov 2024; Chromium/Safari since ~2019). On browsers without support the option is silently ignored — no error, the request just behaves as a normalfetch.Test plan
pnpm --filter @amplitude/analytics-browser test -- test/transport/fetch.test.ts→ 10/10 pass.keepalive.🤖 Generated with Claude Code
Note
Medium Risk
Changes default upload behavior on every browser fetch and interacts with the shared 64 KiB keepalive budget; oversized keepalive requests fail at the network layer, though in-session retries should limit data loss.
Overview
Browser
FetchTransportnow setskeepalive: trueon POST uploads when the outgoing body is within a 32 KiB cap (KEEPALIVE_MAX_BODY_SIZE_BYTES), so typical event batches can finish after redirects or navigation instead of being cancelled.Sizing uses
ArrayBuffer.byteLengthfor gzip bodies and JSON string length as a cheap upper bound for uncompressed payloads (no per-requestBlob). Bodies above the cap omit keepalive and behave like today’s plainfetch.Tests assert keepalive on small/uncompressed and small/compressed requests, and
keepalive: falsefor oversized JSON and mocked oversized gzip output.Reviewed by Cursor Bugbot for commit c18000f. Configure here.