A user sent us 27 performance findings. Here's what was real, what wasn't, and what you can't fix.
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.
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.
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.
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:
git branch lists every local branch and marks the current one with an asterisk. In repos with hundreds of branches, that's slow.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.
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.
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'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.
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 $rcThis is a classic shell scripting bug. Any command between the process you care about and exit $? replaces the exit code. Save it immediately.
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.
These are the most useful findings for other Electron developers, because they set realistic expectations about where your memory and processes actually go.
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.
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.
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.
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.
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.
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);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
});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 });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.
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;
}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-optimizeInterested in developer tools and infrastructure? Token Limit News is our weekly newsletter.