← All posts

Three Rules That Keep an Electron Terminal From Freezing

Electron gets blamed for blocked main threads it did not cause. The execSync ban, why widgets never show a spinner, and the difference between fetching and filtering.

Electron gets blamed for a lot of things that are not Electron's fault. "Electron apps are slow" is usually shorthand for "this specific Electron app blocks its main thread," which is a choice the developer made, not a property of the runtime.

We build a terminal in Electron. A terminal is close to the worst case for this — high-frequency output, a PTY streaming bytes, an agent redrawing a status area several times a second, and a UI full of widgets that all want to fetch something. If the main thread stalls for 400ms, you do not get a slightly janky animation. You get a terminal that drops input while you are typing.

Three rules came out of that, all of them learned the hard way. They are in our contributor docs now, and they generalize to any Electron app.

Rule one: never call execSync in the main process

This is the one that causes the beachball.

Electron's main process is a single Node.js thread that also drives window management and coordinates with the renderer. execSync blocks it. Completely. For the entire duration of the command.

The tempting places to reach for it are exactly the places it hurts most:

// every one of these freezes the whole app
const branch = execSync("git rev-parse --abbrev-ref HEAD").toString();
const result = execSync(`osascript -e '${script}'`);
const version = execSync("node --version").toString();

git rev-parse in a large repository is not instant. osascript regularly takes 200–500ms because it is talking to another process through AppleScript. Call either on a 5-second poll and your app freezes for a fraction of every 5 seconds forever. Users do not report this as "freezing" — they report it as "feels sluggish," which is much harder to track down.

The fix is mechanical:

const { execFile } = require("child_process");
const { promisify } = require("util");
const execFileAsync = promisify(execFile);

async function getBranch(cwd) {
  const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
  return stdout.trim();
}

execFile over exec as a bonus: an argument array is not a shell string, so a branch name with a semicolon in it is data rather than a command.

The only exception we allow is startup code that runs before the window exists. There is no UI to block yet, and sequencing is genuinely simpler.

If you inherit an Electron codebase and want one quick win, grep for execSync in the main process. It is usually there, and it is usually the answer.

Rule two: no loading spinners in widgets

This one sounds like an aesthetic preference and is actually a performance rule in disguise.

The instinct when a panel needs data is to render Loading…, fetch, then swap in real content. It feels responsive and correct. In a terminal it is wrong, for two reasons.

It makes layout depend on data. A spinner and a populated widget are different sizes. Every widget that pops from one to the other reflows its panel, and reflowing a panel next to a terminal means the terminal resizes, which means the PTY gets a resize signal, which means whatever TUI is running inside repaints the whole screen. Open the app with six widgets and your agent's output gets mangled six times in the first two seconds.

It teaches you to wait. A spinner is a promise that something is coming. If the data is a git status that will arrive in 80ms, the spinner is pure noise, and you looked at it.

So the rule is: widgets render immediately with their real structure and empty content, and populate asynchronously. Empty state has the same dimensions as full state. No layout shift, no PTY resize, no repaint.

// no
if (!data) return <Spinner />;
return <CommitList commits={data} />;

// yes — same shape either way
return <CommitList commits={data ?? []} />;

The empty list renders its container at full height with nothing in it. When data arrives, rows appear inside a box that was already the right size. Nothing moves.

This is a good rule well beyond terminals. Any app where a background process cares about the size of a viewport gets the same benefit.

Rule three: fetching and filtering are different operations

The pattern that shows up constantly, and is always wrong:

useEffect(() => {
  fetchEvents({ filter: activeFilter }).then(setEvents);
}, [activeFilter]);   // refetches every time you click a tab

Clicking a filter tab is a UI operation. It should not touch the network, the filesystem, or a child process. But because the filter is in the dependency array, every toggle re-runs the fetch — and in an Electron app that fetch is often an IPC round trip to the main process, which then shells out to git or reads a file.

Toggling a view should be instant. Instead it costs an IPC hop and a subprocess.

The shape that works:

// fetch once, poll on a timer, filter in render
const events = useEvents();                    // owns fetch + interval
const visible = useMemo(
  () => events.filter((e) => matches(e, activeFilter)),
  [events, activeFilter]
);

Cache aggressively. Only invalidate when the parameters that actually determine the data change — the working directory, the session — not when the user changes what they are looking at.

The general form: ask "did the underlying data change, or did the user change what they want to see?" Only the first is a fetch.

The one that is not a rule, just a scar

Popovers rendered into a portal cannot find their trigger by walking the DOM. We learned this by shipping settings popovers that opened in the wrong place whenever the panel had scrolled.

The instinct is to query for the button and read its position. But a portal renders outside the component tree, the query is fragile, and it silently returns the wrong element or none when there are two widgets of the same type on screen.

The fix is to capture the position at the moment of the click, when you unambiguously have the element:

onClick={(e) => {
  const r = e.currentTarget.getBoundingClientRect();
  openSettings({ top: r.bottom, right: window.innerWidth - r.right });
}}

Pass coordinates down. Never make the portal go looking.

What this buys

A terminal where typing never stutters because a widget is polling git. Panels that do not resize the PTY while you are reading agent output. Filter toggles that respond in a frame instead of an IPC round trip.

None of it is clever. It is three rules and one habit, and between them they cover most of what people mean when they say an Electron app feels cheap.

The broader point, which I believe more strongly the longer we work on this: the runtime is rarely the bottleneck. The main thread being asked to do blocking work in a loop is the bottleneck, and that is fixable in any language.

More on how the app is put together in the stack behind MOLTamp, and if you want to build on it, the skinning docs are the place to start.

MOLTamp is a skinnable terminal shell built for agent work — up to 50 sessions, ⌘P search across all of them, per-session monitoring that tells you which agent is waiting, and a community marketplace of skins other people made. Download it.