#1942·rrweb

[Bug]: Network plugin fallback drops HTTP status and basic timing when Resource Timing is unavailable

Author: yukkitCreated Sep 15, 2026Updated Sep 15, 2026

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.

  1. In an empty directory, run npm install --ignore-scripts @rrweb/[email protected].
  2. Save the following as repro.mjs.
  3. Run node repro.mjs and inspect the emitted rows.
repro.mjs
javascript
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:

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.