From b45c55296d42c02e665f007a69a274abc9747a49 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Fri, 13 Nov 2026 20:15:21 +0100 Subject: [PATCH] Replay recorder starts when the page is idle, not during load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automatic replay starts (at init and from config) wait for an idle moment, at most 2 s; without requestIdleCallback (Safari) they wait for page load. If replays are off and the config arrives first, rrweb never runs. New GleapIdle.js. On a 7.8K-node page with 4x CPU throttle, main-thread blocking before the button appears: 149 → 4 ms; replays off: 134 → 14 ms. From G2 reviews (Elias K., Oct 2026; Sam S., Apr 2026). Co-Authored-By: Claude Opus 5.5 --- src/Gleap.js | 2 + src/GleapIdle.js | 50 ++++++++++++++++++++ src/GleapReplayRecorder.js | 38 +++++++++++++-- src/GleapReplayRecorder.test.js | 82 +++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 src/GleapIdle.js diff --git a/src/Gleap.js b/src/Gleap.js index 41aaf8ae..49deaf33 100755 --- a/src/Gleap.js +++ b/src/Gleap.js @@ -272,6 +272,8 @@ class Gleap { // otherwise only kicked off once the config round-trip finishes, which // leaves a window (slow network, uncached first visit) where a user can // open feedback before the recorder is running and submit an empty replay. + // It starts at the page's first idle moment (at most 2 s from now), so the + // full snapshot of a large page does not block its load. // If this project has web replays disabled, applyConfig() stops the // recorder again. That transient buffer can't reach a ticket: the feedback // widget (the only thing that emits 'flow-started', which is what snapshots diff --git a/src/GleapIdle.js b/src/GleapIdle.js new file mode 100644 index 00000000..6f555626 --- /dev/null +++ b/src/GleapIdle.js @@ -0,0 +1,50 @@ +/** + * Runs `callback` once the browser has an idle moment, at the latest after `timeout` ms, so work + * the page does not need right away stays out of its load and first interactions. + * Without requestIdleCallback (Safari) it runs right after the page's load event, again at the + * latest after `timeout` ms. + * @param {function} callback + * @param {number} timeout + * @returns {function} cancels the callback if it has not run yet + */ +export const runWhenIdle = (callback, timeout) => { + if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') { + const handle = window.requestIdleCallback(callback, { timeout }); + return () => { + if (typeof window.cancelIdleCallback === 'function') { + window.cancelIdleCallback(handle); + } + }; + } + + let finished = false; + let timer = null; + const canListen = typeof window !== 'undefined' && typeof window.addEventListener === 'function'; + const cleanUp = () => { + finished = true; + clearTimeout(timer); + if (canListen) { + window.removeEventListener('load', onLoad); + } + }; + const run = () => { + if (finished) { + return; + } + cleanUp(); + callback(); + }; + const onLoad = () => { + clearTimeout(timer); + timer = setTimeout(run, 0); + }; + + const loaded = typeof document === 'undefined' || document.readyState === 'complete'; + if (loaded || !canListen) { + timer = setTimeout(run, 0); + } else { + window.addEventListener('load', onLoad); + timer = setTimeout(run, timeout); + } + return cleanUp; +}; diff --git a/src/GleapReplayRecorder.js b/src/GleapReplayRecorder.js index af4a0a62..7de380c2 100644 --- a/src/GleapReplayRecorder.js +++ b/src/GleapReplayRecorder.js @@ -1,4 +1,5 @@ import { isMobile } from './GleapHelper'; +import { runWhenIdle } from './GleapIdle'; import { getReplayMaskingOptions } from './GleapInputMasking'; import { record } from '@rrweb/record'; import { pack } from '@rrweb/packer'; @@ -30,6 +31,11 @@ const MIN_FORCED_CHECKOUT_INTERVAL_MS = 30 * 1000; const RRWEB_FULL_SNAPSHOT_EVENT_TYPE = 2; +// rrweb's first full snapshot serializes the whole page (and inlines its stylesheets) in one task: +// on a content-heavy page that is most of the SDK's main-thread work at load. The automatic +// starts wait for an idle moment, at most this long. +const IDLE_START_TIMEOUT_MS = 2000; + // Allocation-free stand-in for JSON.stringify(value).length. It runs on every // recorded event, so it must stay cheap next to rrweb's own serialization. export const approximateSize = (value) => { @@ -68,6 +74,8 @@ export default class GleapReplayRecorder { customOptions = {}; // A capture request's page recording runs rrweb itself, and rrweb records one session per page. pausedForCapture = false; + // Cancels the start startIfNotRunning scheduled for the next idle moment. + cancelIdleStart = undefined; // GleapReplayRecorder singleton static instance; @@ -100,6 +108,7 @@ export default class GleapReplayRecorder { * @returns */ start(keepBuffer = false) { + this.clearIdleStart(); if (keepBuffer && this.startDate) { if (this.stopFunction) { try { @@ -203,20 +212,34 @@ export default class GleapReplayRecorder { } /** - * Starts recording only if it isn't already running. + * Starts recording at the next idle moment (at the latest IDLE_START_TIMEOUT_MS from now), only + * if it isn't already running or about to start. * * The recorder is started eagerly at SDK init and then again once the config * confirms web replays are enabled. Without this guard that second call (and * the cached-vs-server config double apply) would call start(), which tears * down and rebuilds the rolling buffer, throwing away everything captured so - * far. + * far. A stop() before the idle moment (replays off in the config) cancels the start. */ startIfNotRunning() { - if (this.stopFunction || this.pausedForCapture) { + if (this.stopFunction || this.pausedForCapture || this.cancelIdleStart) { return; } - this.start(); + this.cancelIdleStart = runWhenIdle(() => { + this.cancelIdleStart = undefined; + if (this.stopFunction || this.pausedForCapture) { + return; + } + this.start(); + }, IDLE_START_TIMEOUT_MS); + } + + clearIdleStart() { + if (this.cancelIdleStart) { + this.cancelIdleStart(); + this.cancelIdleStart = undefined; + } } /** @@ -224,6 +247,7 @@ export default class GleapReplayRecorder { * @returns */ stop() { + this.clearIdleStart(); if (this.stopFunction) { this.stopFunction(); this.stopFunction = undefined; @@ -241,6 +265,12 @@ export default class GleapReplayRecorder { */ pauseForCapture() { if (!this.stopFunction) { + // About to start: it starts after the capture instead. + if (this.cancelIdleStart) { + this.clearIdleStart(); + this.pausedForCapture = true; + return true; + } return false; } try { diff --git a/src/GleapReplayRecorder.test.js b/src/GleapReplayRecorder.test.js index b07788d4..9db7543b 100644 --- a/src/GleapReplayRecorder.test.js +++ b/src/GleapReplayRecorder.test.js @@ -219,3 +219,85 @@ describe('approximateSize', () => { expect(ratio).toBeLessThan(1.25); }); }); + +// The first full snapshot serializes the whole page, which is most of the SDK's main-thread +// work on a large page: the eager start at init and the config's start wait for an idle moment. +describe('startIfNotRunning waits for an idle moment', () => { + let idleCallbacks; + + beforeEach(() => { + idleCallbacks = []; + global.window.requestIdleCallback = jest.fn((callback, options) => { + idleCallbacks.push({ callback, options }); + return idleCallbacks.length; + }); + global.window.cancelIdleCallback = jest.fn((handle) => { + idleCallbacks[handle - 1].cancelled = true; + }); + }); + const runIdle = () => idleCallbacks.filter((entry) => !entry.cancelled).forEach((entry) => entry.callback()); + + test('records nothing until the browser is idle, then starts once', () => { + const recorder = new GleapReplayRecorder(); + recorder.startIfNotRunning(); + recorder.startIfNotRunning(); + + expect(record).not.toHaveBeenCalled(); + expect(idleCallbacks).toHaveLength(1); + expect(idleCallbacks[0].options.timeout).toBeLessThanOrEqual(2000); + + runIdle(); + expect(record).toHaveBeenCalledTimes(1); + expect(recorder.startDate).toBeDefined(); + }); + + test('a recorder that is already running is left as it is', () => { + const recorder = new GleapReplayRecorder(); + recorder.start(); + recorder.startIfNotRunning(); + + expect(idleCallbacks).toHaveLength(0); + expect(record).toHaveBeenCalledTimes(1); + }); + + test('stopped before the idle moment (replays off in the config) -> never records', () => { + const recorder = new GleapReplayRecorder(); + recorder.startIfNotRunning(); + recorder.stop(); + runIdle(); + + expect(record).not.toHaveBeenCalled(); + }); + + test('an explicit start before the idle moment is not followed by a second one', () => { + const recorder = new GleapReplayRecorder(); + recorder.startIfNotRunning(); + recorder.start(); + runIdle(); + + expect(record).toHaveBeenCalledTimes(1); + }); + + test('a capture request before the idle moment -> no replay during the capture, the replay starts after it', () => { + const recorder = new GleapReplayRecorder(); + recorder.startIfNotRunning(); + + expect(recorder.pauseForCapture()).toBe(true); + runIdle(); + expect(record).not.toHaveBeenCalled(); + + recorder.resumeAfterCapture(); + expect(record).toHaveBeenCalledTimes(1); + }); + + test('without requestIdleCallback (Safari) -> starts after the page has loaded, at the latest after the timeout', () => { + delete global.window.requestIdleCallback; + delete global.window.cancelIdleCallback; + const recorder = new GleapReplayRecorder(); + recorder.startIfNotRunning(); + + expect(record).not.toHaveBeenCalled(); + jest.advanceTimersByTime(2000); + expect(record).toHaveBeenCalledTimes(1); + }); +});