The short version
- In September 2025, Electron apps on macOS Tahoe slowed down the whole Mac. The cause was Electron overriding a private AppKit method. Electron patched it within weeks, and Apple fixed it on its side in the macOS 26.2 beta.
- A year later Luminair, also built on Electron, started freezing about once a minute. The Electron in our build already had that patch, and the report was of our app stopping dead, not the Mac lagging.
- We did not guess. We built a freeze recorder into the app that logs any stall of 250 ms or more, with the JavaScript that was running at the time.
- Within minutes it named a quadratic loop (26 s), then a directory read on an iCloud-backed folder (96 s). A week later a separate crash came from reading a 1.2 GB history all at once.
When one app slows every app.
Around the release of macOS 26 Tahoe, people noticed that their Macs felt sluggish whenever Slack, Discord or VS Code was open. The Hacker News thread was titled “Electron-based apps cause system-wide lag on macOS 26 Tahoe”, and it linked a GitHub issue opened on 12 September 2025.
The cause was found by a developer and quoted on Michael Tsai's blog: “Electron was overriding a private AppKit API (_cornerMask) to apply custom corner masks to vibrant views.” macOS uses that method to work out window shadows, and on Tahoe the override made the window server redo work it would normally reuse. The Electron pull request that removed it, merged on 26 September, carries the release note “Fixed excessive WindowServer GPU usage on macOS Tahoe 26.” The Register reported that “the team has backported fixes to versions from 36 on”.
Apps still had to ship the new Electron, and many took weeks. In November, 9to5Mac reported that Apple had worked around it in the macOS 26.2 beta. Its description of the symptom is the important part: “Users would experience issues like stuttery scrolling when interacting with any app (including non-Electron ones), as long as an Electron app window was currently visible.”
“The app completely stops.”
On 1 September 2026 the report came in from our own daily use: “There is a freezing happening about every minute or so. The app completely stops and then continues.”
It is tempting, with an Electron app on a new macOS, to blame the platform. But two facts already pointed elsewhere. Luminair's Electron is version 44, well past the patched releases. And the report was of the app stopping dead, not of the Mac lagging. A window server problem slows everything on screen. An app that stops, then carries on as if nothing happened, points at one busy thread inside it.
An Electron app has two kinds of JavaScript thread that matter here. The main process owns files, engines and windows. Each window has a renderer that draws the interface. If either one runs a long task, the app stops answering until the task ends. From the outside, the question “which one, and doing what?” is hard to answer.
We tried. At idle, the main process sat at 0.0% CPU and the renderer was quiet, so the freeze only happened under real use. Sampling the process from outside with the usual tools showed V8 internals with no function names. And the packaged app turns off the flags that would let a debugger attach from outside, on purpose, for security. So we could not catch the freeze from the outside. The app had to catch it itself.
Always on, and cheap.
The freeze recorder, committed at 19:29 local time that evening, watches both threads all the time:
Event-loop delay
Node's monitorEventLoopDelay “Creates a histogram object that samples and reports the event loop delay over time.” Every five seconds the recorder reads the worst delay and resets it. Anything of 250 ms or more counts as a stall.
Long tasks
The renderer uses the browser's long-task observer. MDN defines a long task as “any uninterrupted period where the main UI thread is busy for 50ms or longer”; the recorder only keeps those of 250 ms or more and sends them to the main process.
A cheap profiler in 60 second windows
A sampling CPU profiler runs on each thread, taking a sample every 20 ms. If a 60 second window has no stall, the profile is thrown away. If it has one, the profile is saved (the last eight are kept) and summarized in one plain-text line: the longest busy stretch, the functions that ate it, and the nearest function of ours that was driving it.
That last detail matters. When a freeze is spent inside JSON.parse or a file read, the profiler's leaf is a built-in. The recorder walks up the stack to the first frame from our own code, so the log line names something we can open and fix. The 20 ms interval was chosen to be fine enough for a 250 ms stall while costing, by the code's own estimate, about half a percent of CPU.
It started logging at 00:38 UTC. Five minutes later it had its first real catch.
None of them was the OS.
1 · 26 seconds in a loop that grew with the square
The first line said the main process had stalled for 26,055 ms, and that 25,667 ms of it was spent in a small helper called typeOf inside trimSessionFile. That function keeps session transcripts under a size cap. It ran once a minute from a background sweep, and at the end of every turn. That is where “about every minute” came from.
The trim keeps the newest lines that fit the budget, then makes sure the kept part still holds at least one user message and one assistant message, so the session can be resumed. Here is how it checked, before the fix:
const keptHasConvo = (s) => { let u = false, a = false; for (let i = s; i < body.length; i++) { const t = typeOf(body[i]); /* JSON.parse */ ... } return false; }; while (start > 0 && !keptHasConvo(start)) start--;
Each step backwards re-parsed every remaining line. On a long session where the newest stretch was all tool output, that is a parse of line after line, again and again: the work grows with the square of the transcript. The fix, in a new file trim-plan.js, parses each line exactly once, makes the conversation check a single backwards pass, and yields to the event loop every 1,500 lines.
The trim also became asynchronous and now re-checks the file before rewriting it, so an engine still appending to the transcript never loses a line.
2 · 29 seconds, then 96, in one directory read
Five minutes after the first catch, the recorder logged 29,259 ms, almost all of it in one readdir. Luminair files sessions into folders by working directory, and the folder name is a lossy encoding of the real path. To turn it back, a helper called bucketCwd walked the real filesystem from /, one directory at a time. It ran uncached on sidebar refreshes, which happen every 1.2 seconds while a transcript is being written.
The 20:01 fix memoized the answer and read the transcript's own recorded folder first. Then the app restarted, and the recorder logged 97,375 ms at boot: 96,364 ms in a single readdir of the same walk. The code comment records where: an iCloud-backed Desktop and Documents, where listing a folder can wait on the cloud. Five session folders still forced the walk: four were empty, and one held files whose recorded folder did not match its name.
Seven minutes later, 88139888 made the rule absolute: the synchronous path never walks the filesystem. It answers from memory or from the transcript, or it returns nothing now, walks in the background with asynchronous reads, and nudges the sidebar when the answer lands.
3 · A week later: 1.2 GB at once
On 7 September a different symptom appeared: the app crashed at startup. The fix commit describes it: a 1.2 GB history was read all at once and V8 ran out of memory. There was no slow loop to find this time, just too much allocated in one go. The fix bounds the work: at most two transcript reads at a time, the ledger read as a stream that keeps only message IDs, and each file read from its tail within a shared 16 MB budget. Cloud notes, which used to be awaited on open, now load in the background so a slow network can never hold up local history.
The same commit added local-only crash diagnostics, because a native out-of-memory crash never reaches JavaScript's error handlers. Dumps stay in the app's support folder and are never uploaded, since they can contain private memory.
Two fingerprints.
If an Electron app on your Mac feels slow, these questions separate the two kinds of problem quickly. They come straight from the difference between the Tahoe reports and our own logs.
The log is already there.
The recorder ships in every Luminair build and is on by default. It writes only to your Mac.
- 1In Finder, choose Go › Go to Folder and open
~/Library/Application Support/claude-studio/perf. The folder name is historical; it is Luminair's. - 2Open stalls.log. Each line is one 60 second window that had a stall: which process, how long, and the functions that took the time.
- 3If Luminair freezes for you, attach the last few lines when you contact support. The saved
.cpuprofilefiles open in Chrome DevTools if you want to look yourself.
Checked, and not claimed.
What this post does not claim
- Which macOS version the freezing Mac was running. The recorder does not log it, and it did not matter once the stall had a function name.
- That Luminair has never been affected by an OS bug. Only that these freezes were not one.
- How long the 1.2 GB crash took to trigger or how often. The commit records the cause and the fix, not a count.
Sources
- Hacker News · 25 September 2025Electron-based apps cause system-wide lag on macOS 26 Tahoe
- GitHub · electron/electron · opened 12 September 2025Electron-based apps cause a huge system-wide lag on macOS 26
- GitHub · electron/electron · merged 26 September 2025fix: MacOS 26 Tahoe - stop overriding private cornerMask API to fix WindowServer GPU load
- Michael Tsai · 30 September 2025Electron Apps Causing System-Wide Lag on Tahoe
- The Register · 2 October 2025Electron patch fixes macOS 26 “Tahoe” slowdown bug
- 9to5Mac · 21 November 2025Latest macOS Tahoe beta fixes bug with Electron apps that caused widespread performance issues
- Node.js docsPerformance measurement APIs: monitorEventLoopDelay
- MDNPerformanceLongTaskTiming
For another quiet failure we only caught by measuring, read Your model didn't get dumber. Your harness might have.
An app that watches itself
Every Luminair build records its own stalls, locally, so a freeze comes with a function name.