Electron setIgnoreMouseEvents slowed my whole PC: the WH_MOUSE_LL hook
Claude (Opus 5.5)
Written by Claude (Opus 5.5) and checked by Claude (Fable 5.2), from the repo, its notes and the session transcripts. An earlier Claude session found the cause and did the rewrite; dested reported the problem, made the calls and edited. The first person below is dested's voice.
My desktop widgets were a countdown, a pomodoro timer, a network graph, an uptime counter and my Claude usage. What I told Claude about them: "As soon as it opens up, my entire computer slows to a crawl and basically stays that slow the entire time."
The cause the code pointed to was one line, win.setIgnoreMouseEvents(true, { forward: true }). On Windows, Electron implements it with a system-wide low-level mouse hook that runs on its main thread. Every mouse move on the machine waited for my app's JavaScript.
Click-through overlays need the hook
The Electron version had one transparent, frameless window per layer per monitor, each covering the whole screen. Widgets were DOM nodes inside it. The design comment explained why:
Adding widget #200 costs a component, not a process
A full-screen window swallows every click, so it was made click-through. forward: true kept mousemove flowing to the renderer, which hit-tested the cursor against widget rects:
lw.win.setIgnoreMouseEvents(true, { forward: true })Electron's docs say forward "forwards mouse move messages to Chromium". They don't say how. On Windows it's this line in shell/browser/native_window_views_win.cc (Electron 37.10.3, the version I shipped):
mouse_hook_ = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, nullptr, 0);Thread id 0 means every thread on the desktop: all mouse input on the PC. The hook is static, one per process, installed when the first window starts forwarding and removed when the last one stops. My windows forwarded whenever the cursor wasn't over a widget, which is almost always.
A low-level hook runs on the thread that installed it
From Microsoft's docs for LowLevelMouseProc:
This hook is called in the context of the thread that installed it. The call is made by sending a message to the thread that installed the hook.
setIgnoreMouseEvents is called from main-process JavaScript, so the hook belongs to Electron's main thread. That's the thread that runs every timer, IPC handler and fs.*Sync call in the main process. While it's busy, the hook's message waits in its queue, and the mouse event waits with it.
There's a ceiling. The hook is supposed to return within LowLevelHooksTimeout, a value in milliseconds under HKEY_CURRENT_USER\Control Panel\Desktop. Since Windows 10 1709 the system caps it at 1000 ms. Past it, Windows moves on, and "on Windows 7 and later, the hook is silently removed without being called."
The same page says how a hook should be run:
If the application must use low level hooks, it should run the hooks on a dedicated thread that passes the work off to a worker thread and then immediately returns.
Electron's hook procedure itself is cheap. It sits on the busiest thread in the app.
My main thread was never idle
The main process did all of this synchronously or on its own event loop:
| Main-process work | When | Size |
|---|---|---|
Walk the transcript tree with readdirSync and statSync |
every 10 s, and 900 ms after each burst of writes | 5,582 files, 24 GB |
Rewrite the usage cache with writeFileSync |
4 s after any change | 8 MB |
Spawn powershell.exe for network counters |
every 2 s | a new process each time |
The transcripts were Claude Code's. I run about 30 sessions at once, all appending to them, so the tree changed all day. Each full walk stalled the hook, and with it the mouse in every app.
The windows added their own cost. Each one was a transparent layered window the size of a 4K monitor, and any change repainted the whole surface.
The diagnosis was a code read
The session that fixed it never opened a profiler. It read the main process with cat, grep and sed, found the setIgnoreMouseEvents call, and measured the one input it couldn't read off the code: the size of the transcript folder. My message to its diagnosis took about two minutes.
Fixing Electron in place kept the hook
Moving the scans to a worker would have shortened the waits. It would still have routed every mouse move on the PC through a JavaScript app's main thread. The decision log rejected it: "The click-through model needs the hook, and per-widget BrowserWindows cost a renderer process each." The old design comment put a renderer at about 50 MB.
So v2 is .NET 10 and WPF, with one native window per widget, sized to the widget. From the decision log: "A window the size of the widget needs no click-through and no mouse hook. A click outside it never reaches the process, and a repaint costs the widget's own pixels." WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE keeps the windows out of Alt+Tab and away from focus. The transcript scanner runs on one below-normal background thread and reads only the last 16 days, from saved byte offsets.
Both commits landed on 2026-10-10: 7ed6dbc keeps the last Electron state, 9097ae5 is the WPF rewrite.
| v2, measured | Result |
|---|---|
| First pass over the last 16 days of transcripts (about 20 GB), background thread | about 9 s |
| Each pass after that, new bytes only | about 100 ms |
| Working set during the scan check | 65 to 136 MB |
| Machine-health recorder added the same day, sampling every second | about 10 ms per sample, 14 ms/s of one core, 42 MB |
I never measured the Electron version's input lag, so there's no matching number for it, and the hook was never profiled: the rewrite removed it along with everything else. After I ran v2 that evening, my reply was "Nailed it."
Check your click-through overlay
If you ship an Electron overlay on Windows, look for the call:
rg -n "setIgnoreMouseEvents\(\s*true\s*,\s*\{\s*forward:\s*true"Any hit means a WH_MOUSE_LL hook on your main thread whenever that window is forwarding. Then:
- Keep the main process empty. No
fs.*Sync, no bigJSON.stringify, no parsing. Move it to a worker thread or a utility process. - Forward only while you need hover. The hook comes off when the last window stops forwarding.
- Size windows to their content. A window that only covers the widget needs no click-through. In Electron that's a renderer per window; outside Electron it's cheap.
- Know the ceiling.
reg query "HKCU\Control Panel\Desktop" /v LowLevelHooksTimeoutshows any override. Mine has none.
For a Windows-only utility that runs all day, I start in .NET now. That rule went into my global Claude instructions the same afternoon.