Defer WebGL bot probing until after the initial pageview response
The browser tracker currently constructs a WebGL context while building the initial pageview payload. On a fresh Chromium process using SwiftShader, that can block the pageview before its fetch begins. I reproduced this using the tagged v2.9.1 tracker, then prepared the bounded source patch below.
The patch leaves the cheap bot signals on the first event and schedules only the WebGL renderer probe at an idle opportunity after the first allowed pageview fetch returns a response. Later events include the existing SwiftShader bit/weight once collected. It does not send another pageview or alter the payload schema, signal weights, server classifier or dependencies. Where requestIdleCallback is absent or throws, it yields to a later timer task. Pending/rejected requests do not start the probe.
A local Chromium diagnostic using fresh processes/profiles dispatched the baseline software-renderer pageview at 629ms and the patched pageview at 21.3ms, with its response at 22.6ms and the probe beginning at 394.1ms. These are individual diagnostic samples; OS/GPU caches were not flushed. The probe still creates a long task, so this is not a claim that total blocking time is fixed. Short visits may never report the weak SwiftShader signal.
Validation against v2.9.1: build passes; full server suite 1,989 tests / 132 files versus untouched baseline 1,966 / 130 using TZ=UTC NODE_OPTIONS=--no-experimental-webstorage npm run test:run on Node 26. Default-environment failures reproduced on untouched v2.9.1. New checks cover event ordering, subsequent-event enrichment, cleanup, prerender activation, skipped/manual pageviews, WebGL failure and scheduler fallback. The matched server accepts all 8,192 valid mask/score combinations; seven representative classification cases are unchanged with blocking on/off. Existing opt-outs remain; no new DNT behavior is introduced.
The attached-in-body source patch is based on commit 87fc1ca5fce340405b2a9b6a02bb96d3f36160c9 (v2.9.1). Generated bundles are omitted; npm run build in server regenerates them. Offered here for upstream consideration; no deployment details or customer data are included.
diff --git a/server/src/analytics-script/botSignals.test.ts b/server/src/analytics-script/botSignals.test.ts
index 3f54e021..30d28ca0 100644
--- a/server/src/analytics-script/botSignals.test.ts
+++ b/server/src/analytics-script/botSignals.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CLIENT_BOT_SIGNAL_MASKS } from "@rybbit/shared";
-import { getBotScore, getBotSignalMask, resetBotScoreCacheForTests } from "./botSignals.js";
+import { getBotScore, getBotSignalMask, resetBotScoreCacheForTests, scheduleWebGlBotSignal } from "./botSignals.js";
function setNavigatorProperty(name: string, value: unknown) {
Object.defineProperty(navigator, name, {
@@ -101,9 +101,7 @@ describe("getBotScore", () => {
resetBotScoreCacheForTests();
setWindowProperty("screen", screen);
- expect(getBotSignalMask(), `${screen.width}x${screen.height}`).toBe(
- CLIENT_BOT_SIGNAL_MASKS.impossibleDimensions
- );
+ expect(getBotSignalMask(), `${screen.width}x${screen.height}`).toBe(CLIENT_BOT_SIGNAL_MASKS.impossibleDimensions);
}
});
@@ -144,8 +142,14 @@ describe("getBotScore", () => {
configurable: true,
});
+ vi.useFakeTimers();
+ expect(getBotScore()).toBe(0);
+ const cancel = scheduleWebGlBotSignal();
+ vi.runAllTimers();
expect(getBotScore()).toBe(1);
expect(getBotSignalMask()).toBe(CLIENT_BOT_SIGNAL_MASKS.swiftShader);
+ cancel();
+ vi.useRealTimers();
});
it("ignores zero outer dimensions and skips the cache while prerendering", () => {
diff --git a/server/src/analytics-script/botSignals.ts b/server/src/analytics-script/botSignals.ts
index 65d91b23..891d00e0 100644
--- a/server/src/analytics-script/botSignals.ts
+++ b/server/src/analytics-script/botSignals.ts
@@ -26,6 +26,8 @@ interface BotSignalResult {
}
let cachedBotSignals: BotSignalResult | null = null;
+let webGlCollected = false;
+let swiftShaderDetected = false;
export function getBotScore(): number {
return getBotSignals().score;
@@ -43,11 +45,14 @@ function getBotSignals(): BotSignalResult {
// A prerendered page reports zero outer dimensions and other non-representative
// state. Never cache a score computed before activation — recompute fresh so
// post-activation events see the real environment.
- if (isPrerendering()) {
- return calculateBotSignals();
- }
- cachedBotSignals ??= calculateBotSignals();
- return cachedBotSignals;
+ const signals = isPrerendering() ? calculateBotSignals() : (cachedBotSignals ??= calculateBotSignals());
+
+ if (!swiftShaderDetected) return signals;
+
+ return {
+ score: Math.min(signals.score + CLIENT_BOT_SIGNAL_WEIGHTS.swiftShader, MAX_CLIENT_BOT_SCORE),
+ mask: signals.mask | CLIENT_BOT_SIGNAL_MASKS.swiftShader,
+ };
}
function calculateBotSignals(): BotSignalResult {
@@ -134,40 +139,6 @@ function calculateBotSignals(): BotSignalResult {
hasPluginOrApiAbsence = true;
}
- // 6. WebGL renderer check — headless/containerized Chrome often uses Google SwiftShader
- try {
- const canvas = document.createElement("canvas");
- const gl = (canvas.getContext("webgl") || canvas.getContext("experimental-webgl")) as WebGLRenderingContext | null;
- if (gl) {
- try {
- const rendererParts: string[] = [];
- const rendererRaw = gl.getParameter(gl.RENDERER);
- if (typeof rendererRaw === "string") {
- rendererParts.push(rendererRaw);
- }
- try {
- type WebGlDebugRendererInfo = {UNMASKED_RENDERER_WEBGL:number};
- const debugInfo = gl.getExtension("WEBGL_debug_renderer_info") as WebGlDebugRendererInfo | null;
- if (debugInfo) {
- const unmaskedRaw = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
- if (typeof unmaskedRaw === "string") {
- rendererParts.push(unmaskedRaw);
- }
- }
- } catch {
- // Firefox Privacy
- }
- if (rendererParts.join(" ").toLowerCase().includes("swiftshader")) {
- addSignal("swiftShader");
- }
- } finally {
- releaseWebGlContext(canvas, gl);
- }
- }
- } catch {
- // WebGL not available — not a bot signal by itself
- }
-
// 7. No plugins — weak supporting signal for Chrome-like UAs only
if ((!navigator.plugins || navigator.plugins.length === 0) && isChromeLike) {
addSignal("emptyPlugins");
@@ -187,6 +158,92 @@ function calculateBotSignals(): BotSignalResult {
};
}
+/** Schedule only after an allowed pageview has been dispatched. */
+export function scheduleWebGlBotSignal(): () => void {
+ let cancelled = false;
+ let idleId: number | undefined;
+ let timerId: number | undefined;
+
+ const collect = () => {
+ idleId = undefined;
+ timerId = undefined;
+ if (cancelled || webGlCollected) return;
+ if (isPrerendering()) {
+ document.addEventListener("prerenderingchange", schedule, { once: true });
+ return;
+ }
+ collectWebGlBotSignal();
+ };
+
+ const schedule = () => {
+ if (cancelled || webGlCollected) return;
+ if (isPrerendering()) {
+ document.addEventListener("prerenderingchange", schedule, { once: true });
+ return;
+ }
+ try {
+ if (typeof window.requestIdleCallback === "function") {
+ idleId = window.requestIdleCallback(collect);
+ return;
+ }
+ } catch {
+ // Some environments expose an unusable idle API; still yield before probing.
+ }
+ timerId = window.setTimeout(collect, 0);
+ };
+
+ schedule();
+ return () => {
+ cancelled = true;
+ document.removeEventListener("prerenderingchange", schedule);
+ if (idleId !== undefined) {
+ try {
+ window.cancelIdleCallback?.(idleId);
+ } catch {
+ // The cancelled guard also protects a callback the host cannot remove.
+ }
+ }
+ if (timerId !== undefined) window.clearTimeout(timerId);
+ };
+}
+
+function collectWebGlBotSignal(): void {
+ if (webGlCollected || isPrerendering()) return;
+ webGlCollected = true;
+ try {
+ const canvas = document.createElement("canvas");
+ const gl = (canvas.getContext("webgl") || canvas.getContext("experimental-webgl")) as WebGLRenderingContext | null;
+ if (gl) {
+ try {
+ const rendererParts: string[] = [];
+ const rendererRaw = gl.getParameter(gl.RENDERER);
+ if (typeof rendererRaw === "string") {
+ rendererParts.push(rendererRaw);
+ }
+ try {
+ type WebGlDebugRendererInfo = { UNMASKED_RENDERER_WEBGL: number };
+ const debugInfo = gl.getExtension("WEBGL_debug_renderer_info") as WebGlDebugRendererInfo | null;
+ if (debugInfo) {
+ const unmaskedRaw = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
+ if (typeof unmaskedRaw === "string") {
+ rendererParts.push(unmaskedRaw);
+ }
+ }
+ } catch {
+ // Firefox Privacy
+ }
+ if (rendererParts.join(" ").toLowerCase().includes("swiftshader")) {
+ swiftShaderDetected = true;
+ }
+ } finally {
+ releaseWebGlContext(canvas, gl);
+ }
+ }
+ } catch {
+ // WebGL not available — not a bot signal by itself
+ }
+}
+
/**
* Chrome caps live WebGL contexts per page (~16) and evicts the oldest when
* exceeded, so the probe context must be released eagerly rather than left
@@ -205,4 +262,6 @@ function releaseWebGlContext(canvas: HTMLCanvasElement, gl: WebGLRenderingContex
export function resetBotScoreCacheForTests() {
cachedBotSignals = null;
+ webGlCollected = false;
+ swiftShaderDetected = false;
}
diff --git a/server/src/analytics-script/tracking.test.ts b/server/src/analytics-script/tracking.test.ts
index 73989fcd..f032d00f 100644
--- a/server/src/analytics-script/tracking.test.ts
+++ b/server/src/analytics-script/tracking.test.ts
@@ -113,6 +113,7 @@ describe("Tracker", () => {
});
afterEach(() => {
+ tracker.cleanup();
vi.clearAllMocks();
});
@@ -465,8 +466,9 @@ describe("Tracker", () => {
it("should clear user ID", () => {
const updateReplayUserId = vi.fn();
(
- tracker as unknown as { sessionReplayRecorder: { updateUserId: (userId: string) => void } }
+ tracker as unknown as { sessionReplayRecorder: { updateUserId: (userId: string) => void; cleanup: () => void } }
).sessionReplayRecorder = {
+ cleanup: vi.fn(),
updateUserId: updateReplayUserId,
};
diff --git a/server/src/analytics-script/tracking.ts b/server/src/analytics-script/tracking.ts
index d602c410..8632d3d0 100644
--- a/server/src/analytics-script/tracking.ts
+++ b/server/src/analytics-script/tracking.ts
@@ -11,7 +11,7 @@ import {
} from "./types.js";
import { findMatchingPattern } from "./utils.js";
import { SessionReplayRecorder } from "./sessionReplay.js";
-import { getBotScore, getBotSignalMask } from "./botSignals.js";
+import { getBotScore, getBotSignalMask, scheduleWebGlBotSignal } from "./botSignals.js";
const FEATURE_FLAG_REQUEST_TIMEOUT_MS = 2000;
@@ -22,6 +22,8 @@ export class Tracker {
private errorDedupeCache: Map<string, number> = new Map();
private errorDedupeLastCleanup = 0;
private exposedFeatureFlags = new Set<string>();
+ private cancelWebGlProbe?: () => void;
+ private cleanedUp = false;
constructor(config: ScriptConfig) {
this.config = config;
this.loadUserId();
@@ -205,6 +207,11 @@ export class Tracker {
mode: "cors",
keepalive: true,
});
+ // A response proves that the pageview was dispatched with cheap signals.
+ // Probe at an idle opportunity; later events can use the completed result.
+ if (payload.type === "pageview" && !this.cancelWebGlProbe && !this.cleanedUp) {
+ this.cancelWebGlProbe = scheduleWebGlBotSignal();
+ }
} catch (error) {
console.error("Failed to send tracking data:", error);
}
@@ -543,6 +550,8 @@ export class Tracker {
// Cleanup
cleanup(): void {
+ this.cleanedUp = true;
+ this.cancelWebGlProbe?.();
if (this.sessionReplayRecorder) {
this.sessionReplayRecorder.cleanup();
}
diff --git a/server/src/analytics-script/deferredWebGl.test.ts b/server/src/analytics-script/deferredWebGl.test.ts
new file mode 100644
index 00000000..ba1d740e
--- /dev/null
+++ b/server/src/analytics-script/deferredWebGl.test.ts
@@ -0,0 +1,284 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { CLIENT_BOT_SIGNAL_MASKS } from "@rybbit/shared";
+import { getBotScore, getBotSignalMask, resetBotScoreCacheForTests } from "./botSignals.js";
+import { Tracker } from "./tracking.js";
+import { ScriptConfig } from "./types.js";
+
+const set = (object: object, key: string, value: unknown) =>
+ Object.defineProperty(object, key, { value, configurable: true, writable: true });
+
+function config(overrides: Partial<ScriptConfig> = {}): ScriptConfig {
+ return {
+ namespace: "rybbit",
+ analyticsHost: "https://analytics.example.com",
+ siteId: "123",
+ visitorId: "visitor",
+ debounceDuration: 0,
+ autoTrackPageview: true,
+ autoTrackSpa: false,
+ trackQuerystring: false,
+ trackOutbound: false,
+ trackErrors: false,
+ enableWebVitals: false,
+ enableSessionReplay: false,
+ sessionReplayBatchSize: 50,
+ sessionReplayBatchInterval: 5000,
+ sessionReplayMaskTextSelectors: [],
+ skipPatterns: [],
+ maskPatterns: [],
+ trackButtonClicks: false,
+ trackCopy: false,
+ trackFormInteractions: false,
+ tag: "",
+ featureFlagsEnabled: false,
+ featureFlags: {},
+ ...overrides,
+ };
+}
+
+describe("deferred WebGL bot signal", () => {
+ let tracker: Tracker;
+ let order: string[];
+ let idle: IdleRequestCallback;
+ let loseContext: ReturnType<typeof vi.fn>;
+ const bodies = () => vi.mocked(fetch).mock.calls.map(([, init]) => JSON.parse(init!.body as string));
+ const runIdle = () => idle({ didTimeout: false, timeRemaining: () => 50 });
+ const pageview = async () => {
+ tracker.trackPageview();
+ await Promise.resolve();
+ };
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ resetBotScoreCacheForTests();
+ order = [];
+ set(document, "prerendering", false);
+ set(navigator, "userAgent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36");
+ set(navigator, "webdriver", true);
+ set(navigator, "plugins", { length: 5 });
+ set(window, "chrome", {});
+ set(window, "screen", { width: 1920, height: 1080 });
+ set(window, "outerWidth", 1200);
+ set(window, "outerHeight", 900);
+ set(window, "innerWidth", 1200);
+ set(window, "innerHeight", 800);
+ set(window, "localStorage", { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => {
+ order.push("fetch");
+ return Promise.resolve({} as Response);
+ })
+ );
+ vi.stubGlobal(
+ "requestIdleCallback",
+ vi.fn(callback => {
+ order.push("schedule");
+ idle = callback;
+ return 1;
+ })
+ );
+ vi.stubGlobal("cancelIdleCallback", vi.fn());
+ loseContext = vi.fn();
+ set(
+ HTMLCanvasElement.prototype,
+ "getContext",
+ vi.fn(() => {
+ order.push("webgl");
+ return {
+ RENDERER: 0,
+ getParameter: () => "Google SwiftShader",
+ getExtension: (name: string) =>
+ name === "WEBGL_lose_context" ? { loseContext } : { UNMASKED_RENDERER_WEBGL: 1 },
+ };
+ })
+ );
+ tracker = new Tracker(config());
+ });
+
+ afterEach(() => {
+ tracker?.cleanup();
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ set(document, "prerendering", false);
+ });
+
+ it("dispatches the first pageview with cheap signals, enriching later events without another pageview", async () => {
+ await pageview();
+ expect(order).toEqual(["fetch", "schedule"]);
+ expect(bodies()).toHaveLength(1);
+ expect(bodies()[0]).toMatchObject({ type: "pageview", _bs: 3, _bsm: CLIENT_BOT_SIGNAL_MASKS.automationApi });
+ const before = tracker.createBasePayload()!;
+ tracker.trackEvent("before-idle");
+ expect(bodies()[1]).toMatchObject({ _bs: 3, _bsm: CLIENT_BOT_SIGNAL_MASKS.automationApi });
+ runIdle();
+ expect(loseContext).toHaveBeenCalledOnce();
+ tracker.trackEvent("after-idle");
+ const after = tracker.createBasePayload()!;
+ expect(after).toEqual({ ...before, _bs: 4, _bsm: before._bsm! | CLIENT_BOT_SIGNAL_MASKS.swiftShader });
+ expect(bodies()[2]).toMatchObject({ type: "custom_event", _bs: 4, _bsm: 9 });
+ expect(bodies().filter(body => body.type === "pageview")).toHaveLength(1);
+ await pageview();
+ expect(requestIdleCallback).toHaveBeenCalledOnce();
+ expect(HTMLCanvasElement.prototype.getContext).toHaveBeenCalledOnce();
+ });
+
+ it("waits for a pageview response before scheduling the probe, without delaying other events", async () => {
+ let respond!: (response: Response) => void;
+ vi.mocked(fetch).mockReturnValue(
+ new Promise(resolve => {
+ respond = resolve;
+ })
+ );
+ tracker.trackPageview();
+ await Promise.resolve();
+ expect(fetch).toHaveBeenCalledOnce();
+ expect(requestIdleCallback).not.toHaveBeenCalled();
+ tracker.trackEvent("while-pageview-pending");
+ expect(fetch).toHaveBeenCalledTimes(2);
+ expect(HTMLCanvasElement.prototype.getContext).not.toHaveBeenCalled();
+ respond({} as Response);
+ await Promise.resolve();
+ expect(requestIdleCallback).toHaveBeenCalledOnce();
+ });
+
+ it("does not schedule for payload inspection or custom events before any pageview", async () => {
+ tracker.createBasePayload();
+ getBotScore();
+ getBotSignalMask();
+ tracker.trackEvent("first-custom-event");
+ expect(fetch).toHaveBeenCalledOnce();
+ expect(requestIdleCallback).not.toHaveBeenCalled();
+ expect(HTMLCanvasElement.prototype.getContext).not.toHaveBeenCalled();
+ });
+
+ it("does not schedule for a skipped pageview", async () => {
+ tracker = new Tracker(config({ skipPatterns: ["/**"] }));
+ await pageview();
+ expect(fetch).not.toHaveBeenCalled();
+ expect(requestIdleCallback).not.toHaveBeenCalled();
+ expect(HTMLCanvasElement.prototype.getContext).not.toHaveBeenCalled();
+ });
+
+ it("supports an explicit pageview when automatic pageviews are disabled", async () => {
+ tracker = new Tracker(config({ autoTrackPageview: false }));
+ expect(requestIdleCallback).not.toHaveBeenCalled();
+ tracker.trackEvent("custom");
+ expect(requestIdleCallback).not.toHaveBeenCalled();
+ await pageview();
+ expect(requestIdleCallback).toHaveBeenCalledOnce();
+ });
+
+ it("yields to a later task when requestIdleCallback is unavailable", async () => {
+ vi.stubGlobal("requestIdleCallback", undefined);
+ await pageview();
+ expect(order).toEqual(["fetch"]);
+ expect(HTMLCanvasElement.prototype.getContext).not.toHaveBeenCalled();
+ vi.runAllTimers();
+ expect(order).toEqual(["fetch", "webgl"]);
+ });
+
+ it("uses the same fallback if the idle API throws", async () => {
+ vi.stubGlobal(
+ "requestIdleCallback",
+ vi.fn(() => {
+ throw new Error("unavailable");
+ })
+ );
+ await pageview();
+ expect(HTMLCanvasElement.prototype.getContext).not.toHaveBeenCalled();
+ vi.runAllTimers();
+ expect(getBotSignalMask()).toBe(9);
+ expect(fetch).toHaveBeenCalledOnce();
+ });
+
+ iSource: rybbit-io/rybbit