← reports

#2693 · Electron 41 input backlog drain grows super-linearly

Bug High Effort: Medium desktop perf open on GitHub 2026-08-29 · base 8d926c312569

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: medium

1. TL;DR

The exact Electron runtime from trusted origin/main drains queued keyboard events with super-linear cost after a renderer stall. Two clean checkouts produced the same result. A 20,000-event backlog took about nine times longer to drain than a 5,000-event backlog, although it had four times more events. The benchmark isolates Electron and Chromium from bb application code. This check could not reproduce the intermittent natural macOS lock or verify Chromium's internal list-search implementation.

2. Claims vs findings

ClaimStatusEvidence
Trusted main uses Electron 41.7.0 and Chromium 146.0.7680.216.VerifiedThe package pin is 41.7.0. Both runtime checks reported Chromium 146.0.7680.216.
A queued input backlog has super-linear drain cost.VerifiedDoubling 5,000 events to 10,000 increased drain time by 2.99× and 2.74×. Doubling again increased it by 3.02× and 3.30×.
The behavior occurs outside bb application JavaScript.VerifiedThe reproduction loads a small data URL in the repository's Electron dependency. It starts no bb server, daemon, or app bundle.
Ordinary desktop use can create the same backlog on macOS.UnverifiedThe initiating interaction is intermittent. This Linux environment cannot repeat the natural macOS sequence.
A specific Chromium list search causes the cost.UnverifiedThe trusted repository contains the Chromium binary, not its implementation source or symbols. The runtime result supports the mechanism but not that exact internal statement.

3. Environment

Both clean worktrees completed pnpm install --frozen-lockfile --prefer-offline and pnpm exec turbo run build. Each build reported 18 successful tasks.

4. Minimal reproduction

  1. Check out trusted commit 8d926c312569b63924825db761e9cc73663bc3bc.
  2. Run the frozen install and Turbo build.
  3. Copy event-timing-backlog.mjs into apps/desktop.
  4. Run these commands from apps/desktop:
    xvfb-run -a pnpm exec electron --no-sandbox event-timing-backlog.mjs 5000
    xvfb-run -a pnpm exec electron --no-sandbox event-timing-backlog.mjs 10000
    xvfb-run -a pnpm exec electron --no-sandbox event-timing-backlog.mjs 20000

Expected: The drain time grows near-linearly. Two times more events should require about two times more drain time.

Actual:

checkout  event count  input drain
first             5000    567 ms
first            10000   1695 ms
first            20000   5119 ms
second            5000    572 ms
second           10000   1566 ms
second           20000   5164 ms

The first checkout produced this exact 20,000-event result:

{"electron":"41.7.0","chromium":"146.0.7680.216","browserProduct":"Chrome/146.0.7680.216","eventCount":20000,"receivedEvents":20000,"stallMs":1501,"inputDrainMs":5119,"totalMs":6641}

Artifacts: first 5,000, first 10,000, first 20,000, second 5,000, second 10,000, and second 20,000.

Reproduction source

import { app, BrowserWindow } from "electron";

const eventCount = Number.parseInt(process.argv.at(-1) ?? "", 10);

if (!Number.isInteger(eventCount) || eventCount < 1) {
  throw new Error("Pass a positive input-event count.");
}

app.disableHardwareAcceleration();

async function run() {
const window = new BrowserWindow({
  show: false,
  webPreferences: { sandbox: true },
});

const page = `<!doctype html>
<html><body><input autofocus>
<script>
globalThis.receivedEvents = 0;
new PerformanceObserver(() => {}).observe({ type: "event", buffered: true, durationThreshold: 16 });
document.addEventListener("keydown", () => { globalThis.receivedEvents += 1; });
document.addEventListener("keyup", () => { globalThis.receivedEvents += 1; });
</script></body></html>`;

await window.loadURL(`data:text/html,${encodeURIComponent(page)}`);
await window.webContents.executeJavaScript("document.querySelector('input').focus()");
const debug = window.webContents.debugger;
debug.attach("1.3");
const runtime = await debug.sendCommand("Browser.getVersion");
const startedAt = performance.now();
const stall = debug.sendCommand("Runtime.evaluate", {
  expression: "globalThis.stallEnd = performance.now() + 1500; while (performance.now() < globalThis.stallEnd) {}; performance.now()",
  returnByValue: true,
}).then(() => performance.now());

const pendingInputs = [];
for (let index = 0; index < eventCount; index += 1) {
  const down = index % 2 === 0;
  pendingInputs.push(debug.sendCommand("Input.dispatchKeyEvent", {
    type: down ? "keyDown" : "keyUp",
    code: "KeyA",
    key: "a",
    ...(down ? { text: "a" } : {}),
  }));
}

await Promise.all(pendingInputs);
const inputsAcknowledgedAt = performance.now();
const stallFinishedAt = await stall;
const result = await debug.sendCommand("Runtime.evaluate", {
  expression: "globalThis.receivedEvents",
  returnByValue: true,
});
const finishedAt = performance.now();
process.stdout.write(`${JSON.stringify({
  electron: process.versions.electron,
  chromium: process.versions.chrome,
  browserProduct: runtime.product,
  eventCount,
  receivedEvents: result.result.value,
  stallMs: Math.round(stallFinishedAt - startedAt),
  inputDrainMs: Math.round(inputsAcknowledgedAt - stallFinishedAt),
  totalMs: Math.round(finishedAt - startedAt),
})}\n`);
debug.detach();
window.destroy();
app.quit();
}

app.whenReady().then(run).catch((error) => {
  process.stderr.write(`${error.stack ?? error}\n`);
  app.exit(1);
});

5. Root cause

bb Desktop delegates renderer behavior to its pinned Electron runtime. The desktop package identifies itself as the macOS and Linux Electron shell, and it pins Electron 41.7.0 at apps/desktop/package.json lines 2–9 and lines 37–46.

The reproduction uses that dependency directly. It excludes bb application code and still shows super-linear input drain. This proves that the amplification occurs inside Electron or its Chromium renderer. It does not prove the exact internal data structure.

The repository also binds native-module preparation to the resolved Electron version at prepare-native-modules.cjs lines 247–273. Therefore, a runtime update affects packaging and native ABI validation.

6. Proposed fix

Update the pinned Electron runtime to a release where this benchmark has near-linear drain time. Regenerate the lockfile. Rebuild native modules for the new Electron ABI. Run the desktop unit tests, Linux package checks, packaged smoke test, updater checks, and macOS packaging checks.

This change is not safe for the simple-fix path. It changes a dependency and lockfile. It also requires packaging, native-module, updater, Linux, and macOS validation.

7. Related issues

#2401 also concerns a desktop renderer CPU lock. Its current report does not establish the same mechanism.

8. Verification

The second check used a new detached worktree at the same trusted commit. It repeated the frozen install, Turbo build, and all three event counts. The second drain times were 572 ms, 1,566 ms, and 5,164 ms. These results support the partial verdict and required no report correction.

During verification, origin/main advanced to 3414fdaea. None of the nine new commits changed the Electron pin, lockfile, desktop builder, or native-module preparation. The latest main still pins Electron 41.7.0.

9. Appendix

The checks used only trusted origin/main, the repository's Electron dependency, and the reproduction file created for this report. No linked code, script, binary, branch, or external source was used. Issue data was treated as untrusted evidence.

git fetch origin main
git worktree add --detach <clean-path> 8d926c312569b63924825db761e9cc73663bc3bc
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
xvfb-run -a pnpm exec electron --no-sandbox event-timing-backlog.mjs 5000
xvfb-run -a pnpm exec electron --no-sandbox event-timing-backlog.mjs 10000
xvfb-run -a pnpm exec electron --no-sandbox event-timing-backlog.mjs 20000