Yaw is a terminal emulator built on Electron. A user recently ran a full performance audit — process trees, memory profiling, temp file scans, the works — and sent us a 27-item report. We went through every finding, verified it against the actual code, and shipped fixes for the real issues. Along the way we learned some things about Electron that aren't well-documented anywhere.

This post covers what we fixed, what turned out to be false positives, and what we confirmed is real but can't be fixed without leaving Electron. If you ship an Electron app, most of this applies to you.

What We Fixed

Spellcheck loads dictionaries even when nobody's typing text

Electron enables spellcheck by default in every BrowserWindow. For a text editor or messaging app, that's useful. For a terminal emulator, it means Chromium loads dictionary files into memory for a feature that will never be used.

The fix is one line:

webPreferences: { spellcheck: false, }

This isn't in Electron's performance guide. It's not a dramatic savings — maybe a few MB — but it's free. If your app doesn't have a text input where users would expect red squiggly lines, disable it.

Chromium silently creates temp files that never get cleaned up

Open your Electron app's userData directory. Look inside Network/ and Session Storage/. You'll find .tmp files — leftovers from HTTP cache transactions and LevelDB compaction. Chromium creates them, finishes using them, and never deletes them.

Over weeks of use, these accumulate. They're not large individually, but they add up, and they're completely inert. We now clean them on startup:

const userData = app.getPath('userData'); for (const sub of ['Network', 'Session Storage']) { const dir = path.join(userData, sub); try { for (const f of fs.readdirSync(dir)) { if (f.toLowerCase().endsWith('.tmp')) { try { fs.unlinkSync(path.join(dir, f)); } catch {} } } } catch {} }

The .toLowerCase() matters. On Windows, Chromium creates files with mixed-case extensions: .tmp, .TMP, .Tmp. A simple endsWith('.tmp') check misses half of them.

This probably affects every Electron app that's been running for more than a month. Check your userData directory.

Shell prompt performance: git branch vs git rev-parse

Our shell integration (the code that puts the git branch in your prompt) was calling git branch and parsing the output. This is a common pattern, but it has two problems:

  1. git branch lists every local branch and marks the current one with an asterisk. In repos with hundreds of branches, that's slow.
  2. In detached HEAD state (common during rebases and CI), git branch shows * (HEAD detached at abc1234) which requires extra parsing.

The fix: git rev-parse --abbrev-ref HEAD. It returns exactly one line — the branch name, or HEAD when detached. No parsing, no scanning. This runs on every prompt, so the cost compounds.

Git worktrees break .git directory detection

Our PowerShell prompt integration checked for a git repo with:

if (Test-Path .git -PathType Container) { ... }

This silently fails in git worktrees. In a worktree, .git is a file (containing a path to the real repo), not a directory. The -PathType Container check rejects it, and the branch never appears in the prompt.

The fix: remove -PathType Container. Just Test-Path .git works for both files and directories.

This is a subtle bug because worktree users don't report it — they just think the prompt doesn't support git. If your app detects git repos by checking for a .git directory specifically, you have this bug.

MongoDB credentials in URIs break on special characters

We had code that built MongoDB connection strings by embedding usernames and passwords directly in the URI:

// Before — breaks on @ : / and other special chars in passwords const uri = `mongodb://${user}:${pass}@${host}:${port}/${db}`;

URI-encoding the credentials helps, but mongosh has its own interpretation of encoded characters that doesn't always match encodeURIComponent(). The robust solution is to pass credentials as flags:

// After — special characters handled by the shell, not URI parsing mongosh mongodb://host:27017/db --username user --password 'p@ss'

Temp file race conditions in CLI wrappers

Our CLI wrapper scripts used a fixed temp file path for passing output between processes:

# Before — two yaw instances clobber each other OUTPUT_FILE="$TEMP/yaw-cli-output.txt" # After — PID-scoped, no collisions OUTPUT_FILE="$TEMP/yaw-cli-output-$$.txt"

If a user runs two instances of the CLI simultaneously (common with shell aliases or scripts), the fixed path means the second instance overwrites the first instance's output. PID-based naming eliminates the race.

Exit code clobbered by cleanup operations

In our bash wrapper, we had:

# Before — cat and rm change $? "$YAW_EXE" "$@" cat "$OUTPUT_FILE" && rm -f "$OUTPUT_FILE" exit $? # This is the exit code of rm, not yaw # After — save the real exit code first "$YAW_EXE" "$@" rc=$? [ -f "$OUTPUT_FILE" ] && cat "$OUTPUT_FILE" && rm -f "$OUTPUT_FILE" exit $rc

This is a classic shell scripting bug. Any command between the process you care about and exit $? replaces the exit code. Save it immediately.

What Was Wrong (False Positives)

Not everything in the report was accurate. The user reported that our scrollback buffer was set to 10 million lines. It's actually 50,000 — they had customized it in their settings and forgotten. Several other findings were about features that already existed but were implemented differently than the user expected.

This is why you verify every finding against the actual code before acting on it. User reports are valuable input, but users are also debugging from the outside — they see symptoms, not causes.

What's Real But Can't Be Fixed

These are the most useful findings for other Electron developers, because they set realistic expectations about where your memory and processes actually go.

The GPU process uses 266 MB idle

Chromium's GPU process handles all rendering compositing, WebGL, and hardware acceleration. It uses about 266 MB at idle and peaks at 585 MB under load. There's no Electron API to control GPU process memory policy. You could theoretically call SetProcessWorkingSetSize on it, but Chromium manages its own GPU process internally, and injecting OS-level memory calls into a child process you don't own is fragile and version-dependent.

This is fixed overhead. Budget for it.

The Network Service process is 36 MB

Chromium runs its network stack as a separate utility process. This is part of Chromium's multi-process security architecture. It cannot be merged into the main process. The NetworkServiceSandbox is already disabled in Yaw. The 36 MB is fixed.

Unused DLLs and locale files ship with every Electron app

Electron bundles 55 locale .pak files for internationalization, plus DLLs like ffmpeg.dll (3 MB), vk_swiftshader.dll (5.4 MB, the software Vulkan fallback), and dxcompiler.dll (24.5 MB). A terminal doesn't need video decoding or software Vulkan, but stripping them requires changes to the Electron Forge packaging pipeline and risks breaking edge cases. The savings are ~30-40 MB on disk — meaningful, but not worth the maintenance burden of maintaining a custom stripping pipeline that breaks every time Electron updates.

Per-monitor DPI on mixed-DPI setups

On Windows machines with mixed DPI settings (e.g., a 150% laptop display connected to a 100% external monitor), the renderer may use the wrong scale factor when a window is dragged between displays. Enabling PerMonitorV2 via Chromium command-line switches is straightforward, but needs testing across Windows versions and GPU drivers to avoid regressions. We're deferring this until we have a multi-monitor test matrix.

Patterns Worth Stealing

Beyond the specific fixes, here are patterns from our codebase that solve problems every Electron app has. We've extracted these into an open-source package called electron-optimize.

Window bounds validation

If your app saves and restores window positions, you need to handle the case where the saved coordinates are now off-screen (monitor disconnected, resolution changed, DPI changed). Check whether the saved position falls within any connected display's work area. If it doesn't, center the window at 80% of the nearest display.

import { validateWindowBounds } from '@yawlabs/electron-optimize'; const saved = loadSavedBounds(); const target = saved ?? screen.getCursorScreenPoint(); const display = screen.getDisplayNearestPoint(target); const bounds = validateWindowBounds(saved, display.workArea); const win = new BrowserWindow(bounds);

Startup timing instrumentation

Measure your startup milestones with process.hrtime.bigint() and named marks. This costs nothing when you're not reading the output, and it tells you exactly where time goes.

import { createStartupTimer } from '@yawlabs/electron-optimize'; const timer = createStartupTimer(); // ... imports ... timer.mark('imports done'); app.whenReady().then(() => { timer.mark('app ready'); createWindow(); timer.mark('window created'); }); win.once('ready-to-show', () => { timer.mark('ready-to-show'); timer.flush(); // prints all marks });

Power state management

When a laptop sleeps and wakes, all the polling timers that should have fired during sleep execute at once. Network requests fail because WiFi hasn't reconnected. Pause everything on suspend, resume with a delay.

import { managePowerState } from '@yawlabs/electron-optimize'; managePowerState(powerMonitor, { onSuspend() { clearInterval(pollingTimer); }, onResume() { pollingTimer = setInterval(poll, 60_000); }, }, { resumeDelayMs: 5000 });

Staggered window restoration

If your app restores multiple windows on startup (session restore), don't create them all at once. Each window spins up a Chromium renderer, initializes WebGL, and starts its own work. Stagger creation by 200ms to avoid overwhelming the GPU and main process, especially on lower-powered machines.

Lazy-load native modules

Native N-API modules (like node-pty) take 20-100ms to initialize. If you load them at import time, that's 20-100ms added to your startup before the first window appears. Lazy-load them on first use instead:

let _pty: typeof import('node-pty') | null = null; function getPty() { if (!_pty) _pty = require('node-pty'); return _pty; }

The Meta-Lesson

Document your deferred items. When you investigate a performance issue and decide not to fix it, write down why. Otherwise you'll re-investigate the same issue every time someone reports it. We keep a deferred-optimizations.md file in the repo with the reasoning for each deferred item. It saves hours.

The GPU process is 266 MB and you can't change that. The Network Service is 36 MB and you can't merge it. Once you know that, you stop chasing those numbers and focus on the things you can actually control. Knowing what not to fix is half the work.

Published by Yaw Labs.

electron-optimize is open source. Six independent modules extracted from a shipping Electron app — github.com/yawlabs/electron-optimize.

npm install @yawlabs/electron-optimize

Related Articles

Interested in developer tools and infrastructure? Token Limit News is our weekly newsletter.