[Bug]: Network plugin fallback drops HTTP status and basic timing when Resource Timing is unavailable
Preflight Checklist
- I searched the issue tracker for a matching bug report. I found the earlier fallback discussion in PR #1105, but not a report about the status/timing fields being lost in the current fallback.
What package is this bug report for?
Other: @rrweb/rrweb-plugin-network-record
Version
2.1.4 (latest npm release checked on 2026-09-15).
The same fallback and polling logic is also present on main at 32ed9fe5387e088cfc023c71a39672c30c516da7. I reproduced the behavior against a fresh, unmodified npm installation of 2.1.4, rather than a patched application bundle.
Expected Behavior
When no matching PerformanceResourceTiming entry is available, a captured fetch/XHR should retain information already available from the observer:
- The actual HTTP response status, when there is a response.
- Locally measured start time and elapsed time, even without detailed Resource Timing data.
- For rejected fetches, elapsed time and an explicit failure indication (for example status
0, if that is the intended convention).
The coarse fallback duration need not claim to be the full Resource Timing duration. Headers and bodies should remain excluded when their recording options are disabled.
Actual Behavior
prepareRequestWithoutPerformance() retains the URL and method but drops status, startTime, and duration, even when the wrapper already knows the HTTP status and start/end timestamps.
The fallback is emitted only after the Resource Timing lookup retries expire (2.75 seconds of scheduled backoff, plus overhead). In a submit-on-demand reporting flow, a snapshot taken soon after a failed request can therefore miss that request entirely; waiting longer yields a row without its status or timing.
The deterministic reproduction below produced:
| Request | Emission after starting the case | Recorded fields with a value |
|---|---|---|
| fetch, HTTP 201 | 2950 ms | name, method |
| fetch, HTTP 503 | 2788 ms | name, method |
| fetch, rejected promise | 2788 ms | name, method |
| XHR, HTTP 500 | 2785 ms | name, method |
All four reported missingFields: ["status", "startTime", "duration"]. Exact wall-clock timings vary. The mocked request starts at 100 and finishes at 145, so the available fallback duration is 45 ms.
Steps to Reproduce
This is an observer-level reproduction using a mocked window to force the missing-entry branch. It does not assume a particular browser condition causes missing entries. Tested with Node.js v24.18.0; no application, collector, or replay UI is needed.
- In an empty directory, run
npm install --ignore-scripts @rrweb/[email protected]. - Save the following as
repro.mjs. - Run
node repro.mjsand inspect the emitted rows.
import { getRecordNetworkPlugin } from '@rrweb/rrweb-plugin-network-record';
async function reproduce(kind, status) {
let now = 100;
const events = [];
const started = Date.now();
let emittedAt;
class MockXHR extends EventTarget {
DONE = 4;
readyState = 0;
status = 0;
open() {}
setRequestHeader() {}
getAllResponseHeaders() { return ''; }
send() {
now = 145;
this.status = status;
this.readyState = this.DONE;
this.dispatchEvent(new Event('readystatechange'));
}
}
const win = {
performance: { now: () => now, getEntriesByName: () => [] },
PerformanceObserver: class { observe() {} disconnect() {} },
XMLHttpRequest: MockXHR,
fetch: async () => {
now = 145;
if (status === 0) throw new TypeError('Simulated network failure');
return new Response(null, { status });
},
};
const plugin = getRecordNetworkPlugin({
initiatorTypes: [kind],
recordHeaders: { request: false, response: false },
recordBody: false,
recordInitialRequests: false,
});
const stop = plugin.observer((event) => {
events.push(event);
emittedAt = Date.now() - started;
}, win, plugin.options);
try {
if (kind === 'fetch') {
await win.fetch('https://example.com/save', { method: 'POST' }).catch(() => {});
} else {
const xhr = new win.XMLHttpRequest();
xhr.open('POST', 'https://example.com/save');
xhr.send();
}
const immediateEventCount = events.length;
await new Promise((resolve) => setTimeout(resolve, 3200));
const request = events[0]?.requests[0];
console.log(JSON.stringify({
kind, expectedStatus: status, immediateEventCount, emittedAfterMs: emittedAt,
recorded: request,
missingFields: ['status', 'startTime', 'duration'].filter((key) => request?.[key] === undefined),
}));
} finally {
stop();
}
}
await Promise.all([
reproduce('fetch', 201),
reproduce('fetch', 503),
reproduce('fetch', 0),
reproduce('xmlhttprequest', 500),
]);Additional Information
Relevant source at the checked commit:
- Resource Timing polling/backoff.
- XHR fallback call.
- Fetch wrapper and fallback call.
- Fallback serializer.
A local workaround passes the captured HTTP status and start/end times into the fallback serializer and includes status, startTime, and duration. For rejected fetches, it also sets the end time in finally; for XHR, it captures status before awaiting the timing lookup. Running the same four cases with this workaround preserves the expected fields.
We also removed the polling wait locally so fallback diagnostics are available promptly. That is a separate latency tradeoff: an upstream fix could preserve the existing wait, make it configurable, or use an immediate fallback with later enrichment. Removing the wait entirely can forgo detailed Resource Timing fields that arrive later. The primary correctness issue is that the fallback drops already-known status and timing regardless of the retry policy.
Source: rrweb-io/rrweb