
Profiling an Electron App's Real Memory Hogs Before a Rust Rewrite
On this page
Introduction: A Rust Rewrite Is a Hypothesis, Not a Diagnosis
This post is the memory-profiling procedure I run before anyone on the team is allowed to say the word "rewrite": per-process sampling with app.getAppMetrics(), per-renderer metrics over CDP, a Playwright-driven workload that makes runs comparable, three-snapshot leak attribution, and the cost model I use to decide whether a Rust rewrite would actually move the number that hurts. The worked example is a synthetic message-list bug I reproduced in a harness, so the numbers below are inspectable rather than anecdotal.
The trigger for this kind of question is usually a headline. Discord's CTO confirmed in early November 2026 that the desktop and mobile clients are being rebuilt in Rust. The press coverage that followed attached a number to the motivation: Windows memory use that could reach 4GB, dropping to roughly 290MB in the rebuilt app. Both figures can be accurate and still tell you nothing about your Electron app.
"Up to 4GB" is a peak — observed after some unknown session length, on some unknown build, under some unknown workload. "Roughly 290MB" is a steady-state number from a different program with a different architecture, a different feature set, and whatever the rebuild had shipped on the day someone measured it.
A rewrite is a hypothesis about what is causing the memory. It might be right. It might also be that 70% of your growth comes from three caches you wrote, and you could delete them in a week.
My position, stated up front: most Electron memory complaints are retention bugs in application code, not the cost of Chromium, and you cannot tell which one you have without a time series. A single reading from Task Manager cannot distinguish a 300MB floor from a 300MB leak that has been running for forty minutes. What follows is the instrumentation and decision procedure behind that claim.
What Actually Counts as Memory in an Electron App
V8 heap, external memory, and shared memory are three different budgets
process.memoryUsage() in a renderer (or main) process reports several fields, and conflating them is the most common measurement error:
// main process, quick sanity read
const m = process.memoryUsage();
console.log({
heapUsedMB: (m.heapUsed / 1048576).toFixed(1), // live + garbage V8 objects
heapTotalMB: (m.heapTotal / 1048576).toFixed(1), // V8's reserved pages
externalMB: (m.external / 1048576).toFixed(1), // native memory V8 tracks
arrayBuffersMB: (m.arrayBuffers / 1048576).toFixed(1), // ArrayBuffer/Buffer backing stores
rssMB: (m.rss / 1048576).toFixed(1), // what the OS actually gave the process
});
heapUsed covers JavaScript objects and nothing else. A decoded 4000×3000 image sitting in a <canvas> or an ImageBitmap is not there — it lives in Blink or GPU-side memory. A Node Buffer carrying a file's bytes lands in external plus arrayBuffers, and V8 has limited insight into when to reclaim it. Codecs, SQLite pages, and native modules allocate wherever they like, outside every number you can read.
The practical consequence: if your heap chart is flat and the process still grows, stop staring at the heap. You have an external or native allocator problem, and the tools in the next section are the wrong ones.
The process model: main, renderer, GPU, utility, and crashpad
A modern Electron app is not one process. app.getAppMetrics() tells you what you actually have running:
| Type | What it is | Typical behaviour |
|---|---|---|
Browser | Main process: Node, your app logic, window management | Slow, roughly flat growth if you're careful |
Tab | Renderer: one per BrowserWindow / webview / WebContentsView | Where the interesting leaks live |
GPU | Compositor, rasterisation, GPU command buffer | Steps up with surface/bitmap count, not with JS |
Utility | Network service, audio, video capture, spellcheck | Small, stable |
Zygote / Sandbox helper | Fork helpers (platform-dependent) | Constant overhead |
crashpad_handler | Crash reporting | Constant, tiny |
Summing only the renderer is a classic mistake: it hides the GPU process, which is exactly where decoded images and large composited layers show up.
Why Task Manager and DevTools report different memory numbers for the same Electron app
Four reasons, all of them boring:
- Shared pages are counted per-process by most task managers. Chromium's shared memory and common DLLs get attributed to every process that maps them, so summing a column double-counts.
app.getAppMetrics()gives youmemory.sharedBytesseparately for exactly this reason. - DevTools' Memory panel measures the JS heap of one renderer. It knows nothing about the GPU process, native modules, or the main process.
- DevTools being open changes the measurement. It attaches a debugger, may retain object graphs for the console, and can suppress collections.
performance.memoryis quantised. Chrome intentionally bucketsusedJSHeapSizeunless--enable-precise-memory-infois passed. I've watched people build leak detectors on values that only move in coarse steps and conclude "no leak."
app.getAppMetrics() is the closest thing to an apples-to-apples figure, because it's Electron's own view of each process's working set. Use it as the spine of your harness and drop to CDP when you need detail.
Baseline Electron Memory Instrumentation You Already Have
app.getAppMetrics() and process.getProcessMemoryInfo()
Two Electron APIs, no dependencies. app.getAppMetrics() returns an array of ProcessMetric objects with pid, type, name, cpu, and a memory object holding workingSetSize, peakWorkingSetSize, privateBytes, and sharedBytes — all in kilobytes.
process.getProcessMemoryInfo() (callable in any process) returns a promise of { private, residentSet, shared }, also in kilobytes. The distinction that matters: workingSetSize is what the OS has resident; privateBytes excludes shared pages and is the better answer to "how much does this process uniquely cost me."
Pulling per-frame numbers from webContents and the CDP Performance domain
For per-renderer detail, attach the debugger and read the Performance domain. JSHeapUsedSize, Nodes, Documents, and TaskDuration give you the shape of the renderer's live state without a snapshot.
// Attach to a window's webContents and return a sampling function.
const { webContents } = require('electron');
async function createPerfProbe(wc) {
const dbg = wc.debugger;
dbg.attach('1.3');
await dbg.sendCommand('Performance.enable');
return async function sample() {
const { metrics } = await dbg.sendCommand('Performance.getMetrics');
const get = (name) =>
metrics.find((m) => m.name === name)?.value ?? null;
return {
jsHeapUsedMB: get('JSHeapUsedSize') / 1048576,
jsHeapTotalMB: get('JSHeapTotalSize') / 1048576,
nodes: get('Nodes'),
documents: get('Documents'),
listeners: get('JSEventListeners'),
taskDurationSec: get('TaskDuration'),
};
};
}
module.exports = { createPerfProbe };Nodes and JSEventListeners are the two underrated fields. A message list that re-renders without detaching listeners shows JSEventListeners climbing steadily while the heap merely looks "busy."
Writing a sampling harness that records a time series, not a single reading
This is the piece people skip. One sample is a photograph of a crowd; you need the film.
// main-process harness. Collects a labelled sample per call.
const { app } = require('electron');
const fs = require('node:fs');
const samples = [];
function collect(label) {
const procs = app.getAppMetrics().map((m) => ({
pid: m.pid,
type: m.type,
name: m.name,
workingSetMB: m.memory.workingSetSize / 1024,
peakWorkingSetMB: m.memory.peakWorkingSetSize / 1024,
privateMB: m.memory.privateBytes / 1024,
sharedMB: m.memory.sharedBytes / 1024,
}));
const byType = {};
for (const p of procs) {
byType[p.type] = (byType[p.type] ?? 0) + p.workingSetMB;
}
const totalMB = procs.reduce((a, p) => a + p.workingSetMB, 0);
const sample = { t: Date.now(), label, totalMB, byType, procs };
samples.push(sample);
fs.appendFileSync('mem-series.jsonl', JSON.stringify(sample) + '\n');
return sample;
}
globalThis.__harness = {
sample: collect,
reset: () => { samples.length = 0; },
dump: () => samples,
};
app.whenReady().then(() => {
// interval sampler so the series is dense even when nothing calls sample()
setInterval(() => collect('interval'), 5000).unref();
});The choices there are deliberate: working-set sums that double-count shared bytes, a byType breakdown so you can see the double-counting, and JSONL appended per sample so a crash still leaves you data.
Defining a Reproducible Workload for Memory Profiling
Driving a session with Playwright and a scripted fixture
Anecdote is the enemy. Playwright's Electron support launches the real app, drives it, and samples from the main process without you clicking anything.
import { writeFileSync } from 'node:fs';
const app = await electron.launch({ args: ['.', '--profile-mode'] });
const win = await app.firstWindow();
await win.waitForSelector('[data-testid="message-list"]');
// Warm-up: same shape as steady state, excluded from the leak fit.
await win.evaluate(() => window.__fixture.runPass({ messages: 500 }));
async function pass(n) {
await win.evaluate(() => window.__fixture.runPass({ messages: 2000 }));
await app.evaluate(() => globalThis.__harness.sample('pass'));
await win.waitForTimeout(2000);
}
for (let i = 0; i < 6; i++) await pass(i);
const series = await app.evaluate(() => globalThis.__harness.dump());
writeFileSync('mem-series.json', JSON.stringify(series, null, 2));
await app.close();The fixture itself has to be deterministic: fixed message bodies, fixed image sizes, no network, no timers firing at random intervals. Pull from a real server and your memory benchmark now depends on someone else's deploy schedule.
Warm-up, steady state, peak, and leak rate are four different measurements
Separating them is the whole reason to build the harness:
| Metric | Definition | What it decides |
|---|---|---|
| Floor | Total working set, empty window, 60s idle | The denominator for everything else |
| Warm-up peak | Highest total during the first N seconds | Startup perception, not long-session pain |
| Steady state | p50/p95 over a fixed workload after warm-up | Whether a normal session fits in memory |
| Leak rate | Least-squares slope over the steady-state window | Whether hour 6 is survivable |
A leak rate only means something if the workload is identical across the window. Otherwise you're measuring feature usage, not retention.
Recording the environment so the numbers survive review
Every series file should carry a header. Without it, nobody — including future you — can compare two runs.
const env = {
electron: process.versions.electron,
chromium: process.versions.chrome,
node: process.versions.node,
v8: process.versions.v8,
platform: `${process.platform}-${process.arch}`,
osRelease: os.release(),
totalMemMB: Math.round(os.totalmem() / 1048576),
argv: process.argv.slice(1),
devToolsOpen: false, // set this honestly
buildType: app.isPackaged ? 'packaged' : 'dev',
};
buildType is not optional. A dev build with source maps and an unminified bundle can easily double the renderer's resident set, and I've watched people "reproduce" a 900MB renderer that was 380MB packaged.
Reading an Electron Heap Snapshot Without Drowning
Capturing snapshots over CDP and loading them in Chrome DevTools
Electron's debugger streams snapshot chunks as events. Collecting them yourself skips the DevTools UI entirely and lets you capture from CI.
const fs = require('node:fs');
async function writeHeapSnapshot(wc, filePath) {
const dbg = wc.debugger;
const chunks = [];
const onMessage = (_event, method, params) => {
if (method === 'HeapProfiler.addHeapSnapshotChunk') {
chunks.push(params.chunk);
}
};
dbg.on('message', onMessage);
try {
await dbg.sendCommand('HeapProfiler.enable');
await dbg.sendCommand('HeapProfiler.takeHeapSnapshot', {
reportProgress: false,
});
} finally {
dbg.removeListener('message', onMessage);
}
fs.writeFileSync(filePath, chunks.join(''));
return { filePath, bytes: chunks.reduce((a, c) => a + c.length, 0) };
}
module.exports = { writeHeapSnapshot };Two operational notes: snapshot files are routinely 500MB–2GB for a real app, and taking one forces a full GC — so a snapshot changes the thing you are measuring. Take them at labelled points, not continuously.
For lighter attribution, HeapProfiler.startSampling / HeapProfiler.stopSampling produces an allocation profile with stacks and a selfSize per node. A 32768-byte sampling interval gives you a usable profile at low overhead; that's my default when I want to know where allocations come from rather than what survived.
Shallow size, retained size, and dominators
- Shallow size — the object's own bytes. A
stringshows its character count; the array holding it shows only pointer bytes. - Retained size — everything that would be freed if this object were collected, its retained set.
- Dominator — an object with no path to the root that bypasses it. The dominator tree removes double-counting, so a shared 10MB buffer referenced by 500 rows appears once, at its dominator, not 500 times.
Sort by retained size, not shallow. In practice the top of the retained list is a handful of objects holding everything else, and they're usually containers you'd never suspect: a module-scoped Map, a Redux slice, an event-emitter registry, a chrome.storage-style mirror.
The three-snapshot technique for isolating a leak
The procedure, verbatim:
- Snapshot A after warm-up and after forcing GC.
- Run workload pass B. Force GC. Snapshot B.
- Run an identical workload pass C. Force GC. Snapshot C.
- In DevTools, open the Memory panel, select snapshot C, switch the view to Comparison against B, and sort by Delta and # New.
Objects allocated in pass B and still alive after pass C are your leak candidates. Objects allocated in B and freed in C are just churn — normal, ignore them. The comparison view is why you need three snapshots, not two.
To force GC you need the flag before the app starts:
electron . --js-flags="--expose-gc"
then call global.gc() from the renderer between passes. This belongs in a profiling build only; it is not something you leave on.
The Usual Memory Hogs in Real Electron Apps
Unbounded renderer caches: message history, virtual lists, and decoded images
Three sub-species, one root cause: an array or Map with a push and no evict.
- Message history: the store keeps every message received this session, even though the list renders 30 rows.
- Virtual lists: the
<ul>is windowed, which makes it feel fixed, but the data source behind it isn't. Virtualisation solves DOM cost, not retention. - Decoded images:
ImageBitmap,<canvas>backing stores, and CSS-decoded images live outside the JS heap. A cache keyed by URL holding 2,000 bitmaps at 16MB each is 32GB of "not my heap."
The last one is why the renderer's heap chart can look innocent while the GPU process climbs.
IPC payload copying and structured clone costs on large objects
ipcRenderer.send / invoke serialise with the structured clone algorithm. A 5MB object becomes a serialised buffer in the sender, a copy in the receiver, and often a transient copy in the main-process hop. Peak cost is a small multiple of the payload; if the receiver keeps it, the cost is permanent, and if the receiver sends it back you've doubled it.
For high-volume streams, use MessageChannelMain and post over the MessagePort directly instead of routing every message through the main process. And check your listeners: ipcRenderer.on handlers that close over large objects keep them alive for the life of the renderer.
Native modules, ArrayBuffers, and external memory that V8 never frees
Anything with a native backing store needs an explicit lifecycle. A Buffer retained by a module-level array is not reclaimed because you nulled your reference to the wrapper — the native allocation's lifetime is yours to manage. Same for ArrayBuffers handed to native code, WebAssembly memories (large, and they grow in page-sized increments), and SQLite page caches.
process.getBlinkMemoryInfo() gives you Blink's own accounting (allocated, total, bookkeeping, blockCount); if that total diverges from the JS heap, the gap is your external allocation.
Dev-only weight: source maps, unminified bundles, and HMR artifacts
I'll say it plainly: if your memory benchmark runs a dev build, delete the benchmark. Source maps alone can add hundreds of megabytes of parsed mappings. HMR keeps module-level state for replaced modules that a full reload would have dropped, so a dev session accumulates state production physically cannot. React DevTools, Redux DevTools, and anything with "time-travel" inflate retained sets substantially.
Measure app.isPackaged === true, no DevTools attached, minified, no source maps. If the problem only exists in dev, you don't have a memory problem, you have a build-config problem.
Attributing Memory to Features Instead of Processes
Instrumentation markers and allocation timelines
Process-level numbers answer "which process," not "which feature." For the second question, wrap feature actions in markers and take heap readings on both sides of a forced GC:
function measureFeature(name, fn) {
if (!global.gc) {
throw new Error('Run with --js-flags="--expose-gc" for attribution builds');
}
global.gc();
const before = performance.memory.usedJSHeapSize;
const result = fn();
global.gc();
const after = performance.memory.usedJSHeapSize;
performance.mark(`${name}:retained`);
console.log(JSON.stringify({
feature: name,
retainedMB: (after - before) / 1048576,
}));
return result;
}This is a measurement-build helper, not production code. It's also only a heap number — for images and native allocations you need the process-level delta from the same marker window, which is what the harness gives you.
performance.memory is quantised in Chrome unless --enable-precise-memory-info is set. Small retained deltas will read as zero. Use the flag for attribution builds, or you'll conclude the leak isn't there.
Worked example: a message list that never evicts
Here's the bug I reproduced in the fixture. It's plausible app code, not a strawman:
// renderer/message-store.js — the bug
const messages = []; // module scope, lives for the renderer's lifetime
export function onIncoming(raw) {
const msg = {
id: raw.id,
author: raw.author,
body: raw.body, // ~2KB average in the fixture
attachments: raw.attachments.map((a) => ({
url: a.url,
bitmap: a.bitmap, // ImageBitmap, held for the session
})),
};
messages.push(msg); // no eviction, ever
render(messages);
}
export function getMessages() {
return messages;
}The virtualised list renders 30 rows. messages renders none, and holds everything.
The fix bounds both item count and byte size, and evicts from the head:
// renderer/message-store.js — bounded, byte-aware
const DEFAULTS = { maxItems: 2000, maxBytes: 32 * 1024 * 1024 };
function estimateBytes(msg) {
// rough, deliberately cheap: string chars are ~2 bytes, plus fixed overhead
return 192 + msg.body.length * 2 + msg.attachments.reduce(
(a, att) => a + 64 + att.url.length * 2,
0,
);
}
class MessageLog {
#items = [];
#bytes = 0;
constructor(opts = {}) {
this.limits = { ...DEFAULTS, ...opts };
}
push(msg) {
const size = estimateBytes(msg);
this.#items.push({ msg, size });
this.#bytes += size;
// evict from the oldest end until both budgets fit
while (
this.#items.length > this.limits.maxItems ||
this.#bytes > this.limits.maxBytes
) {
const dropped = this.#items.shift();
if (!dropped) break;
this.#bytes -= dropped.size;
// bitmaps live outside the JS heap — close them explicitly
for (const att of dropped.msg.attachments) {
if (att.bitmap && typeof att.bitmap.close === 'function') {
att.bitmap.close();
}
}
}
}
toArray() { return this.#items.map((e) => e.msg); }
}
export const messageLog = new MessageLog();The bitmap.close() call is the part people forget. Dropping the JS reference to an ImageBitmap does not immediately release the underlying graphics memory; closing it does.
Harness output before and after a targeted fix
Numbers from the synthetic fixture (a scripted pass of 2,000 messages with an 8MB-per-pass image set, on one Linux workstation). Reproduce it on your own machine — the shape is the transferable finding, not the digits.
Before:
label totalMB Browser Tab GPU Utility
pass-0 412 98 271 28 15
pass-1 486 99 344 28 15
pass-3 651 101 507 28 15
pass-5 803 102 658 28 15
Everything is in Tab. Browser, GPU, and Utility stay flat to within 4MB across five passes. That table alone rules out "Chromium is bloated" — if the floor were the problem, Browser and GPU would be the columns growing.
After the bounded store and bitmap.close():
label totalMB Browser Tab GPU Utility
pass-0 425 98 284 28 15
pass-1 431 99 289 28 15
pass-3 428 100 286 28 15
pass-5 433 100 290 28 15
Slope went from roughly 1.2 GB/hour to roughly 3 MB/hour, and Tab settled near 288MB instead of climbing past 650MB. The floor didn't move — expected, because I changed application retention, not the runtime.
Native Memory: Where V8 Tooling Stops
vmmap, heaptrack, and Windows Performance Recorder
When the process grows but every JavaScript number is flat, you're in native territory:
- macOS:
vmmap --summary <pid>for the region breakdown, thenvmmap <pid> | sortto find the big ones. Look atIOKit,MALLOC_LARGE, andImage IOregions — decoded images and CGImage caches show up there, not in V8. - Linux:
heaptrack ./node_modules/.bin/electron .followed byheaptrack_gui, orvalgrind --tool=massiffor a coarser picture.heaptrackgives you an allocation flame graph with call stacks, the closest thing to "who allocated this" outside the JS heap. - Windows: Windows Performance Recorder with the heap profiling provider, analysed in Windows Performance Analyzer.
wpr -start Heap -filemodearound the workload, thenwpr -stop trace.etl. Sysinternals VMMap gives the per-process region view equivalent tovmmap.
Take the native measurement while the JS heap is quiet. If the heap is climbing too, you'll spend an hour reading malloc stacks that are just V8's backing store growing.
Measuring Electron's own floor with an empty window
Before any of this means anything, measure the floor: launch a window with an empty DOM, wait 60 seconds, sample. Whatever that number is — summed across Browser, Tab, GPU, Utility, crashpad — that's your runtime tax, and every feature's cost is measured against it.
Treat published figures as priors, not results. An empty Electron window typically lands in the low hundreds of megabytes of summed working set, and the exact number moves with Electron version, GPU driver, platform, and whether hardware acceleration is on. Measure yours. Write it down. It's the denominator for every "Electron costs too much" argument you'll have this year.
Separating Chromium overhead from your code
The subtraction is:
your_code_cost ≈ steady_state_total − floor
If steady_state_total is 430MB and your floor is 240MB, your code owns ~190MB. That's the number a rewrite has to beat, and it's a much harder target than the 4GB headline suggests. It reframes the question, too: the rewrite doesn't have to beat 4GB, it has to beat 190MB plus whatever growth you failed to fix. Fix the growth first, because you'd have to fix it in Rust as well.
Deciding Between Fixing Your Electron App and a Rust Rewrite
What the reported 4GB-to-290MB drop does and does not tell you
What the public reporting establishes: a CTO confirmed the desktop and mobile clients are being rebuilt in Rust, and press coverage cites Windows memory dropping from as much as 4GB to around 290MB.
What it does not establish, and what I'd want before using it as a planning input:
- The workload. "Up to 4GB" is a peak. Peaks come from long sessions with many guilds, many images, many messages. Is the 290MB figure the same session shape, or a fresh launch?
- The measurement boundary. Task Manager per-process sums double-count shared pages. Does 290MB include the GPU and compositor processes, or only the app's own?
- The build stage. A rebuilt client at feature parity with the old one is a different measurement from one that hasn't shipped every feature yet.
- The UI toolkit. The reports I read don't state which Rust UI stack is in use, or whether any web content is still embedded. If a webview survives for parts of the UI, the memory floor survives with it.
None of that makes the drop implausible. It's what you'd expect from moving a per-message JS object graph into compact native structures with an explicitly bounded cache and a renderer that only draws visible rows. The interesting question isn't whether Rust can be smaller — it can — it's how much of the delta came from the language and how much from finally being forced to bound the caches.
A cost model: memory floor, peak, and growth per hour
Write it as three terms, not one number:
peak(h) ≈ floor + working_set(workload) + growth_per_hour × h
Plug in a real decision:
| Scenario | floor | working set | growth/h | 8h session peak |
|---|---|---|---|---|
| Current Electron (measured) | 240MB | 190MB | 1,200MB | 10.0GB |
| Electron + bounded caches | 240MB | 190MB | 3MB | 455MB |
| Electron + process split per workspace | ~300MB | 120MB/process | 3MB | 444MB (2 procs) |
| Native rewrite (hypothetical) | ~120MB | ~90MB | unknown | ~234MB |
The current build is unusable at hour 8. The fixed build fits comfortably. The hypothesised rewrite saves another ~200MB — real, but an order of magnitude smaller than the leak. That gap is the whole argument, and it only becomes visible once you have a time series.
Side-by-side: targeted fixes, process splitting, and a native rewrite
| Approach | Memory upside | Engineering cost | Risk | When it's right |
|---|---|---|---|---|
| Bound caches, close bitmaps, fix IPC | Large if the leak dominates; near-zero otherwise | Days to weeks | Low | Always first. You need the measurement anyway |
Split heavy features into separate processes / WebContentsViews | Moderate floor increase, big peak reduction, crash isolation | Weeks | Medium — cross-process state | One feature dominates the working set |
| Native rewrite | Large floor reduction, uncertain working set | Quarters, plus a UI toolkit and platform-parity plan | High — feature parity, accessibility, IME, theming | Growth is unfixable and the floor is the binding constraint |
My rule: a rewrite is justified when the floor — not the growth — is what's hurting users, and you've already proven the growth isn't yours. If a fixed Electron build fits your memory budget for a realistic session, ship the fix and revisit the rewrite on your own timeline instead of a headline's.
Regression Gates So the Memory Leak Does Not Come Back
Memory budget assertions in CI with the same harness
A harness is only worth having if it runs without a human. Fit a slope over the steady-state window and assert two thresholds:
import { readFileSync } from 'node:fs';
const BUDGET = {
steadyStateP95MB: 520,
growthMBPerHour: 60, // deliberately generous for noisy CI runners
};
const series = JSON.parse(readFileSync('mem-series.json', 'utf8'))
.filter((s) => s.label === 'pass');
function slopeMBPerHour(samples) {
const n = samples.length;
if (n < 3) throw new Error('need at least 3 steady-state samples');
const xs = samples.map((s) => (s.t - samples[0].t) / 3_600_000);
const ys = samples.map((s) => s.totalMB);
const mx = xs.reduce((a, b) => a + b, 0) / n;
const my = ys.reduce((a, b) => a + b, 0) / n;
let num = 0;
let den = 0;
for (let i = 0; i < n; i++) {
num += (xs[i] - mx) * (ys[i] - my);
den += (xs[i] - mx) ** 2;
}
return den === 0 ? 0 : num / den;
}
const totals = series.map((s) => s.totalMB).sort((a, b) => a - b);
const p95 = totals[Math.floor(totals.length * 0.95)] ?? totals.at(-1);
const growth = slopeMBPerHour(series);
console.log({ p95MB: p95.toFixed(1), growthMBPerHour: growth.toFixed(1) });
const failures = [];
if (p95 > BUDGET.steadyStateP95MB) {
failures.push(`steady state p95 ${p95.toFixed(1)}MB > ${BUDGET.steadyStateP95MB}MB`);
}
if (growth > BUDGET.growthMBPerHour) {
failures.push(`growth ${growth.toFixed(1)}MB/h > ${BUDGET.growthMBPerHour}MB/h`);
}
if (failures.length) {
console.error('Memory gate failed:\n' + failures.join('\n'));
process.exit(1);
}Two practical caveats. Shared CI runners have noisy neighbours, so absolute thresholds will flap; either self-host the runner or gate on the slope (comparatively robust) and treat absolute numbers as a warning. And keep the gate on the packaged build — a dev-build gate produces false positives until everyone learns to ignore the job.
The gate protects the fix, not the architecture. It will catch a reintroduced unbounded cache. It will not catch a whole new subsystem that adds 200MB at startup — that needs the floor assertion, measured against the empty-window baseline.
What to keep measuring after a Rust rewrite ships
A native client doesn't make the question disappear; it changes the units. Keep the same four measurements — floor, warm-up peak, steady state, growth per hour — and the same discipline of a scripted workload. Rust will not save you from an unbounded HashMap of decoded textures, and a native app that keeps every message in memory will grow just as faithfully, only from a lower starting point. Retained-set analysis becomes allocator profiling (heaptrack, WPA, Instruments): different toolchain, same question. Who is holding this, and why hasn't it been released?
Conclusion: Measure the Hog, Then Choose the Runtime
The 4GB-to-290MB story is real and worth reading, but it's a conclusion drawn at the end of a long measurement process you didn't see. Take the shortcut, start your rewrite from the headline, and you'll spend two quarters rebuilding the client only to discover you ported the leak along with everything else.
Do this instead, in order:
- Build the harness:
app.getAppMetrics()per process, CDPPerformance.getMetricsper renderer, JSONL out. - Script the workload with Playwright and a deterministic fixture, and record the environment so the numbers survive review.
- Separate floor from steady state from growth. Report all three.
- Run the three-snapshot comparison and fix the top retained objects. Close your bitmaps.
- Re-measure. If the growth is gone and the peak fits your budget, you're done — and you have the data for a much better-informed rewrite decision later.
The single most useful number I've gotten out of this process isn't any absolute figure; it's the slope before and after. The second most useful is the floor, because it tells you the best case a rewrite can ever reach and how much of your problem it actually solves. Measure the hog first. Then pick the runtime.
Further Reading
- Electron
app.getAppMetrics()— official documentation for theProcessMetricshape and memory fields. - Electron
process.getProcessMemoryInfo()— per-processprivate/residentSet/sharedvalues. - Electron
debuggerAPI — how to attach CDP from the main process. - CDP
Performancedomain andHeapProfilerdomain — the metric names and snapshot streaming used here. - Chrome DevTools: record heap snapshots — the comparison view and three-snapshot workflow.
- Playwright Electron support — launching and driving a real Electron app.
- heaptrack — native allocation profiling for Linux.
- Windows Performance Analyzer — heap and virtual-allocation analysis for the Windows cases.
- Windows Latest: Discord's String of Electron exits — the press report behind the 4GB-to-290MB figures; note it is a report, not a published benchmark methodology.
More posts

Testing Topcoat's Rust SSR Against Next.js on Cold Starts and Type Errors
A hands-on comparison of Topcoat's Rust server-side rendering and Next.js, measuring cold-start performance and type-error handling while weighing Rust's safety and speed against ecosystem maturity and compile-time costs.

Why TypeScript 7's VS Code Benchmark Won't Predict Your CI Build Times
A deep dive into TypeScript 7's 11.9x VS Code build benchmark, explaining why synthetic compiler wins don't directly translate to CI build times and what migration risks and editor feedback changes to watch.

Break-Even Math for Self-Hosted GLM-5.1: GPU Utilization, Ops Overhead, and Claude API Rates
This deep technical walkthrough calculates the break-even point for self-hosting GLM-5.1 versus Claude API rates by modeling GPU utilization, hardware costs, and operations overhead.
Loading comments…