← Blog Architecture 26 September 2026 9 min read

The 26 second freeze was ours, not Tahoe's

In the fall of 2025, Electron apps were making whole Macs stutter on macOS Tahoe. So when Luminair, an Electron app, began to stop dead about once a minute, the operating system was the obvious suspect. It was the wrong one. Here is how we found out, and why every fix came after a measurement.

Fig 01  One evening, three stalls, two fixesReal data · recorder log and git · times UTC, 2 Sep 2026
00:30 00:40 00:50 01:00 01:10 recorder 4842f123 trim fix c6307c9f walk fix 88139888 recorder on 26.1 s 00:43 · MAIN PROCESS typeOf in trimSessionFile 25,667 ms of 26,055 29.3 s 00:48 · MAIN one readdir 29,234 ms 97.4 s 01:06 · MAIN, AT BOOT one readdir in the folder walk: 96,364 ms STALLS.LOG Measured first, then fixed.
Stall lengths and function names are copied from stalls.log on the developer Mac where it happened; commit times from git (committed at 19:29, 20:01 and 20:08 local, UTC minus five). Bar heights are proportional to seconds. The recorder logs a stall at the end of its 60 second window, so each stall began up to a minute before its mark.

The short version

  1. 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.
  2. 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.
  3. 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.
  4. 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.
02The Tahoe lag

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 Tahoe bug made the whole Mac slow. Ours made one app stop. Those are different fingerprints.
03Our freeze

“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.

04A recorder inside the app

Always on, and cheap.

The freeze recorder, committed at 19:29 local time that evening, watches both threads all the time:

MAIN

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.

UI

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.

BOTH

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.

05Three stalls, three causes

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:

desktop/main.js · before c6307c9flines 21180–21185
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.

Fig 02  Parses needed to find the conversationSchematic · counts, not timings
Before EVERY STEP RE-PARSES about n² / 2 parses · 60,000 lines ≈ 1.8 billion After ONE PARSE PER LINE n parses, yielding every 1,500 lines · 60,000 lines = 60,000 Top: the newest line is on the right; each row is one step back. Ticks: yields to the event loop.
The worst case the test suite now checks: 60,000 tool lines with the only user and assistant pair at the very top. The parse counts are arithmetic, not measurements. The fix commit reports 60,000 lines planned in 75 ms, and an oracle test checks that the new plan keeps exactly what the old one kept.

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.

The lesson we took“The OS is slow” is a theory. A stall log with a function name is a fact. We committed the recorder first and changed code only after it named a function; both freeze fixes quote the log line they answer. The crash got its own local diagnostics for the same reason.
06Is it the OS or the app?

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.

Fig 03  Two fingerprintsSources and our log
Tahoe Electron lag (2025)Our freeze (2026)
Other appsStutter too, including non-Electron onesNot part of the report; a blocked app thread does not touch them
The app itselfLags along with everything elseStops completely, then continues
Where the time goesThe window server, on the GPUOne JavaScript thread in the app
Goes away whenThe app's windows are hidden or minimizedThe long task finishes
Fixed byA newer Electron, or macOS 26.2Changing the app's own code
The left column summarizes the GitHub issue, Hacker News thread and 9to5Mac report linked above. The right column comes from the original report and Luminair's stall log, 1 and 2 September 2026.
07Find it on your Mac

The log is already there.

The recorder ships in every Luminair build and is on by default. It writes only to your Mac.

  1. 1In Finder, choose Go › Go to Folder and open ~/Library/Application Support/claude-studio/perf. The folder name is historical; it is Luminair's.
  2. 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.
  3. 3If Luminair freezes for you, attach the last few lines when you contact support. The saved .cpuprofile files open in Chrome DevTools if you want to look yourself.
08What we checked

Checked, and not claimed.

250 ms
Stall threshold for both the main process and the renderer
20 ms
Profiler sampling interval, in 60 second windows; quiet windows are discarded
38/38
Recorder, trim, read-budget and boot tests pass on the current tree
44
Electron major version in the build that froze (since 31 August 2026); the Tahoe fix went into 36 and later

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

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.

Download Luminair →