
Electron vs Rust Desktop Memory: Measuring Working Sets Instead of Trusting 290MB Headlines
On this page
The 290MB Figure Is a Claim, Not a Measurement
Discord's reported Rust desktop client is being quoted at roughly 290MB of RAM and held up as proof that Electron was the problem all along. This post does not argue that number up or down. It does something more useful: it shows how to measure a desktop client's memory properly — summed private working set across the process tree, sampled against a fixed workload on Windows, macOS, and Linux — and then uses that method to separate what a Rust plus webview rewrite genuinely changes from what it leaves exactly as it was.
What the reporting says about Discord's Rust desktop client
During the first week of November 2026 a cluster of outlets — Basic Tutorials, ShiftDelete.Net, Technobezz, BigGo Finance — published variations on one story: Discord is rebuilding its desktop client in Rust, moving off Electron, and an early Windows build reportedly uses about 290MB of RAM. The framing was identical across all of them. The rewrite gets presented as the fix for years of memory complaints, especially on Windows 11, where the Electron client has allegedly reached multi-gigabyte footprints in bad cases.
I have no reason to doubt a rewrite is happening or that a Rust build exists. What I doubt is that 290MB is a number anyone should use to reason about framework choice.
Why an idle-RAM headline is the least useful number in the story
One memory figure, from an unnamed build, at an unspecified uptime, under an unspecified workload, tells you almost nothing. Memory in a desktop chat client is a curve, not a value. It moves with how many guilds you keep open, how warm the image and emoji caches are, whether a voice channel is connected, how long the process has been alive, and which of the app's six processes you decided to add up.
Sit 290MB next to "multi-gigabyte" with no shared methodology and you don't have a comparison. You have two anecdotes in a trench coat.
What this post does instead: build a repeatable working-set comparison
So I'll skip the headline argument and hand you the rig instead: the counter to use, the workload script, the sampling window, and the table you should publish if you ever make a memory claim in public. Every command below runs against your own app — PowerShell performance counters on Windows, /proc/<pid>/smaps_rollup on Linux, footprint on macOS. A method you can execute beats a number you have to trust.
I never had access to Discord's Rust build, so there is no measured 290MB-versus-X result here. Where sample output appears, it is labelled illustrative. Any numeric comparison not attributed to the reporting demonstrates the method; it is not a benchmark.
Separating Confirmed Reporting From Inference
Confirmed: the rewrite, the platform, and the rough figures the outlets repeat
These are the points the published reports agree on, dated to their own timestamps:
| Claim | Status | Source of claim |
|---|---|---|
| Discord is rebuilding its desktop client in Rust and moving away from Electron | Reported by multiple outlets, published 2026-11-05/06 | Aggregator summaries listed above |
| An early Windows build was measured at roughly 290MB of RAM | Reported, method not stated | Same |
| The current Electron client has been observed at multi-gigabyte RAM in "bad cases" | Reported, method not stated | Same |
| The rewrite is framed as a response to memory complaints on Windows 11 | Reported | Same |
The rewrite itself is the durable fact. The numbers are the part that needs work.
Unverified: exact build, workload, uptime, and what "multi-gigabyte" was measured against
Here's what the reports never say:
- Build identity. No version string, commit, or channel. An early internal Windows build and a shipped binary are different measurement subjects.
- Measurement window. Was 290MB taken thirty seconds after launch, or after six hours with twenty servers open?
- Workload. Idle on the server list, or joined to voice with a screen share running?
- Which counter. Private working set, commit charge, or the sum of every process's RSS? On the same app these differ by a factor of two or more.
- The comparison target. Was "multi-gigabyte" measured on the same machine, same account, same uptime — or is it a remembered worst case from a support thread?
Why I will not treat an aggregator summary as a benchmark methodology
Aggregator copy inherits numbers from a primary write-up without the methodology that made them mean anything. By the fourth outlet a figure has lost its build number, its uptime, and its counter definition, and picked up a comparison it never had. I'll take the reporting as evidence that a rewrite is underway. I won't take it as evidence about Electron's cost, because that isn't what it measures.
If you want the primary-source version of this story, wait for a first-party engineering post from Discord with an actual methodology section. That's the artifact worth citing, and as of writing I haven't seen one described.
"RAM Usage" Is at Least Four Different Numbers
Private working set vs commit charge vs shared pages
On Windows, the counters people call "RAM usage" are not interchangeable:
- Working set — pages currently resident in physical RAM for that process, including pages shared with other processes.
- Working set – private — the resident subset mapped by this process alone. Closest thing to "what this app costs me right now".
- Private bytes / commit charge — virtual memory the allocator has committed. Often far larger than the resident set, because it's what the OS reserves, not what's in RAM.
- Shared pages — image pages (executables, DLLs), shared memory segments, font files, and the OS's own mapped pages. Resident once, counted in every process that maps them.
Get-Process exposes two of these and names them badly: WorkingSet64 is the total working set with shared pages included, and PrivateMemorySize64 is commit charge, not resident private memory. Neither is the number you want by itself.
How Chromium shared memory inflates the sum of per-process counters
Chromium's multi-process model means plenty of pages get mapped several times: the binary, ICU data, fonts, compositing shared memory, the V8 snapshot. Sum WorkingSet64 across eight processes and every shared page is counted up to eight times.
That's the mechanism behind most "Electron is using 2GB!" screenshots — not a lie exactly, but a sum of overlapping measurements. Linux gives you a better tool for this. PSS (proportional set size) divides each shared page by the number of processes mapping it, so summing PSS across a process tree approximates the real footprint instead of overcounting it.
Why Task Manager, Activity Monitor, and ps disagree on the same app
- Windows Task Manager groups the process tree for known apps and shows something close to total working set including shareable pages. It also reflects memory compression and page sharing, which nothing else reports identically.
- Activity Monitor reports "Memory" from
phys_footprint, which excludes some shared and compressed pages that RSS includes. psreports RSS, which double-counts shared pages and ignores compressed memory entirely.
Three tools, three definitions, one app. Mix the tools in a comparison and you measured the tools.
The number you should pick, and the number marketing picks
For an app that ships a runtime, the defensible number is summed private working set across the process tree, sampled over time. It answers the question users actually ask: how much of my 8GB laptop does this thing eat?
Marketing picks the lowest single figure from any counter at any moment in the session, compared against the highest figure from the predecessor under a heavier workload. That's a selection effect wearing a lab coat.
Measuring a Windows Desktop Client's Working Set Properly
Pulling working-set counters with PowerShell instead of reading a screenshot
Get-Process won't give you private working set. Performance counters will. Confirm the counter names on your build first, because they aren't stable across versions:
Get-Counter -ListSet 'Process V2' |
Select-Object -ExpandProperty Counter |
Select-String -Pattern 'Working Set|Private Bytes'
You should see \Process V2(*)\Working Set - Private and \Process V2(*)\Private Bytes. If instance names look truncated, list them explicitly before trusting any -like filter:
(Get-Counter '\Process V2(*)\Working Set - Private').CounterSamples |
Select-Object InstanceName, CookedValue |
Sort-Object CookedValue -Descending |
Select-Object -First 10
The sum function, wrapped so the rest of the script stays readable:
function Get-TreeMemory {
param([string]$NamePattern = 'discord*')
$ws = (Get-Counter '\Process V2(*)\Working Set - Private' -ErrorAction SilentlyContinue).CounterSamples |
Where-Object { $_.InstanceName -like $NamePattern -and $_.Status -eq 0 }
$commit = (Get-Counter '\Process V2(*)\Private Bytes' -ErrorAction SilentlyContinue).CounterSamples |
Where-Object { $_.InstanceName -like $NamePattern -and $_.Status -eq 0 }
[pscustomobject]@{
Procs = @($ws).Count
PrivateWS_MB = [math]::Round((($ws | Measure-Object CookedValue -Sum).Sum) / 1MB, 1)
Commit_MB = [math]::Round((($commit | Measure-Object CookedValue -Sum).Sum) / 1MB, 1)
}
}
Get-Counter with wildcards is slow. Hundreds of instances means seconds per call, so budget for that in the sampling loop rather than assuming a 30-second interval costs 30 seconds.
A fixed workload script: launch, idle, join a voice channel, scroll, idle again
Sampling is scriptable. Clicking isn't, so the workload has to be a written procedure you repeat exactly:
- Sign out, kill every process, clear the app's cache directory.
- Launch. Timestamp
T+0. Do not touch the window. T+0:00toT+0:05— idle on the server list, no channel selected.T+0:05— open one text channel with at least 50 messages of history and images. Scroll to the top once, then stop.T+0:08— join a voice channel, stay connected, muted, no video.T+0:12— open the member list and the emoji picker once each, then close them.T+0:15toT+0:45— hands off. This is where the slope lives.
Write those timestamps down. A memory number without a workload step beside it is unreadable six weeks later.
Recording samples over 30 minutes instead of one snapshot
$rows = @()
$start = Get-Date
while (((Get-Date) - $start).TotalMinutes -lt 30) {
$m = Get-TreeMemory -NamePattern 'discord*'
$rows += [pscustomobject]@{
Minute = [math]::Round(((Get-Date) - $start).TotalMinutes, 2)
Procs = $m.Procs
PrivateWS_MB = $m.PrivateWS_MB
Commit_MB = $m.Commit_MB
}
Start-Sleep -Seconds 30
}
Then compute the slope instead of eyeballing it. Slope is what separates a large baseline from an actual leak:
$n = $rows.Count
$mx = ($rows.Minute | Measure-Object -Average).Average
$my = ($rows.PrivateWS_MB | Measure-Object -Average).Average
$num = 0.0; $den = 0.0
for ($i = 0; $i -lt $n; $i++) {
$num += ($rows[$i].Minute - $mx) * ($rows[$i].PrivateWS_MB - $my)
$den += [math]::Pow($rows[$i].Minute - $mx, 2)
}
'{0:N2} MB/min' -f ($num / $den)
Sample output shape — values illustrative, not measured against Discord:
Minute Procs PrivateWS_MB Commit_MB
0.02 6 284.5 712.3
0.52 6 285.9 713.0
5.02 7 301.4 748.6
12.02 8 342.8 801.2
29.52 8 351.0 806.9
slope: +2.19 MB/min
Read that shape carefully. Process count went 6 → 7 → 8 as the workload opened a channel and a voice connection. Most of the growth is step changes at workload events, not a steady climb. The pattern that indicates a leak is a flat process count with a positive slope on a pure idle run.
The results table I would publish alongside any claim
Anyone publishing a memory comparison should ship a table with these fields filled in. Less than that is a headline.
| Field | Why it matters | Example (illustrative, not measured) |
|---|---|---|
| Build / commit | A number without an identity can't be reproduced | 1.0.0-canary.42 (a1b2c3d) |
| Machine, RAM, OS build | 8GB vs 32GB changes cache pressure | 16GB, Win 11 26H1 |
| Uptime at each sample | Separates baseline from slope | 0s / 5min / 30min |
| Private working set (sum) | The cost on a real machine | 351.0 MB |
| Commit charge (sum) | What the OS reserves | 806.9 MB |
| Peak working set | Catches the first-paint spike | 640 MB |
| Process count over time | Reveals renderer accumulation | 6 → 8 |
| Idle CPU (5-min avg) | Battery and thermals | 0.4% |
| Leak slope over 30 min idle | Leak vs baseline | +2.19 MB/min |
| Workload script | The procedure that produced it | Steps 1–7 above |
The Same Working-Set Measurement on macOS and Linux
ps and RSS limits, and when to reach for smaps_rollup
On Linux, RSS is a trap for the same reason WorkingSet64 is: shared pages get counted per process. Use /proc/<pid>/smaps_rollup, which hands you PSS pre-aggregated:
for pid in $(pgrep -f discord); do
awk -v p="$pid" '
/^Pss:/ {pss += $2}
/^Private_Dirty:/ {pd += $2}
END {printf "%s pss=%d kB private_dirty=%d kB\n", p, pss, pd}
' /proc/$pid/smaps_rollup
done
Sum the PSS column across PIDs and you have something roughly comparable to summed private working set on Windows. Use smaps_rollup rather than smaps: the full file runs to megabytes per process and the aggregation is already done for you.
On macOS, RSS from ps is the number everyone quotes and the one that overcounts:
ps -Ao pid=,rss=,comm= | awk 'tolower($3) ~ /discord/ {rss += $2} END {printf "%.1f MB total RSS\n", rss/1024}'
For something closer to Windows' private working set, footprint reports phys_footprint, which excludes shared file-backed pages and accounts for compressed memory:
footprint -p "$(pgrep -f Discord | head -1)" | head -40
Keeping the workload identical across operating systems
The same seven steps have to run on every OS, including the "don't touch it" windows. Harder than it sounds. macOS throttles background timers more aggressively when the window is occluded, and Linux compositors disagree about whether an offscreen window keeps rendering. If you want cross-OS numbers, keep the window visible and focused on all three machines and say so in the write-up.
Why cross-platform numbers from different machines are not comparable
A 290MB macOS figure and a 290MB Windows figure are not the same measurement. Different counters, different shared-memory accounting, different RAM totals and therefore different cache pressure, different code paths for font rendering, GPU compositing, and audio. Putting them side by side in one table implies a precision nobody has.
If you must compare platforms, compare within a platform across versions of the same app. That's a real experiment. Cross-platform memory tables are entertainment.
Where Electron's Memory Actually Goes
The process model: browser, GPU, utility, and one renderer per window
Electron inherits Chromium's process architecture: a browser (main) process running your Node code, a GPU process, one or more utility processes for audio, network, and storage, and a renderer per BrowserWindow or BrowserView. The published process model docs are the map.
The practical consequence is that your app's memory is a sum over processes you didn't write, and a BrowserWindow you "closed" with hide() is a full renderer still holding its heap and its caches.
V8 heap growth versus retained DOM and image caches
When people actually profile this, the V8 heap usually isn't the problem. A chat client's renderer heap is often a few tens of megabytes. What dominates:
- The retained DOM for a long message list — every node, every wrapper object.
- Decoded image bitmaps held by the compositor and the image cache. A 1280×720 image is ~3.7MB decoded at 4 bytes per pixel, no matter that the JPEG on disk is 200KB. A hundred of those in scrollback is 370MB.
- Blob URLs the app created and never revoked, pinning the backing store.
Which is how "V8 heap looks fine" and "the app is using 900MB" end up true at the same time.
What an always-on client accumulates: timers, websockets, animation frames
Discord-style clients stay open for days. They accumulate:
- Reconnect timers. Every failed gateway reconnect can leave a
setTimeoutthat cleanup missed. - Websocket buffers. A socket that reconnects without releasing its old receive buffer leaks slowly and steadily.
- Animation frames. A
requestAnimationFrameloop on a hidden window that never paused is pure waste;backgroundThrottlingexists for exactly this. - Event listeners on
windowregistered by a component that unmounted without removing them.
None of these are framework bugs. All of them show up as a positive slope on the 30-minute idle run, and all of them survive a rewrite to a different shell.
Idle footprint versus leak slope over hours of uptime
Two different problems, two different fixes:
| Symptom | What it means | Fix |
|---|---|---|
High constant footprint from T+0 | Runtime baseline + warm caches | Process model, lazy windows, cache caps |
| Flat at 30 min, climbing at 6 h | Genuine leak | Heap snapshots, listener audit, timer audit |
| Step jumps at workload events | Expected cache growth | Budget the growth; cap the cache |
| Step jumps with no workload | Spawned renderer never reclaimed | Lifecycle audit |
A rewrite changes the first row. It does nothing for rows two through four.
What a Rust Plus Webview Desktop Client Actually Changes
Shared system webview runtime versus a bundled Chromium
An Electron app ships its own Chromium and Node. Two Electron apps on the same machine with different Electron versions share essentially nothing at the code-page level, because they're different images. Five Electron apps can mean five copies of the renderer payload on disk and in RAM.
A client built on WebView2 on Windows, WKWebView on macOS, and WebKitGTK on Linux uses the OS-provided runtime. The browser process for that runtime is shared infrastructure, and the platform patches it independently. That's the real architectural win: not "Rust is faster", but "you stopped shipping a browser".
Native shell with a webview surface versus a fully native UI
"Rust client" is carrying a lot of weight in those headlines. At least three architectures hide behind it:
- Rust shell + embedded webview rendering the existing UI. Smallest delta from the current codebase, smallest RAM win over Electron's browser and Node processes, keeps every DOM cost.
- Rust shell + native UI for the chrome, webview for rich content. Real win on whatever you move native, but now you maintain two rendering models.
- Fully native UI. Biggest win, biggest cost.
The reports describe a Rust rebuild without specifying which of these it is. Based on how these migrations usually go, my guess (untested) is that the first shipped build lands closest to option 1 or 2, with native surfaces arriving incrementally. If a first-party description of the architecture exists, that's the document worth reading.
Startup time and binary size are separate axes from RAM
These three get conflated constantly:
- Binary/installer size — an Electron installer carries the Chromium payload plus Node; a webview-based app carries your assets. Largest and least arguable difference here, roughly an order of magnitude in the usual case, and also the least interesting to users, who install once.
- Cold start time — usually better without a bundled runtime spawn, but DOM construction and first render often dominate, and the shell doesn't change those.
- Steady-state RAM — the most cited and the least affected by the shell swap.
Winning on size and startup while barely moving steady-state memory is a common and completely reasonable outcome. It just isn't the story the headlines told.
What the architecture does not fix: DOM size, image caches, renderer memory
Render the same React tree into a WebView2 surface and you get the same retained DOM, the same decoded image cache, the same per-renderer heap. WebView2 will not garbage-collect your virtualized list for you. All that changed is who pays for the browser binary and the Node main process — plus, maybe, one fewer process if the shell replaces the Node main.
Anyone telling you the framework swap alone explains a 10x memory difference is describing a difference in workload, not architecture.
The Bill Comes Due Elsewhere: Accessibility and Platform Parity
Screen reader behaviour through native-webview bridges
Electron gets accessibility nearly free: Chromium implements UIA on Windows, AX on macOS, and AT-SPI on Linux, and those implementations get exercised by the largest browser userbase on earth. A custom shell has to expose the webview's tree through the host's accessibility API, and quality varies by platform and host version.
This is the most under-discussed cost of leaving Chromium-everywhere, and the one most likely to hand you a bug report you can't close in a sprint. If NVDA or JAWS users are among your users, accessibility belongs in the migration's acceptance criteria, not in a follow-up ticket.
Keyboards, IME, high-DPI, and per-OS divergence
Chromium normalizes a pile of things you never think about: IME composition, keyboard event ordering with modifiers, high-DPI scaling and fractional scale factors, clipboard MIME negotiation, drag-and-drop semantics, font fallback. Three webviews means three sets of quirks, and the QA matrix triples.
I'm not claiming the quirks are unmanageable. I'm claiming the decision framework has to price them, because they land at the worst possible time — right when launch is scheduled.
Developer velocity: one Chromium target versus several OS webviews
Electron's actual product is a single rendering target whose Chromium version you control. One set of CSS workarounds, one set of feature detections, one set of bug reproductions, and no dependency on which WebView2 runtime the user happens to have installed.
With system webviews you inherit the platform's version skew: an evergreen WebView2 runtime that isn't, an older Safari on a machine that won't update, a distro WebKitGTK two years behind. You gain RAM and lose the ability to say "this is what the renderer does."
My position: for a team of five shipping a cross-platform client, that velocity cost usually outweighs the RAM win.
Why "Electron Is Bloated" Is the Wrong Lesson
Process boundaries, lazy loading, and budget enforcement as the real levers
The lesson I'd take from the reporting isn't "Electron bad". It's that a client which spawns renderers generously, keeps every cache warm, and never releases anything will grow no matter what renders the pixels. The levers that actually move the number:
- Don't create windows you aren't showing.
hide()is not free;destroy()is. - Throttle what's hidden. Background windows should not be running animation frames.
- Cap caches explicitly. Image caches, code caches, and blob URL lifetimes should have a ceiling you chose.
- Measure on a schedule, not once during a review.
Concrete Electron practices: defer windows, cap caches, kill idle renderers
// main.js — trimmed for brevity, error handling omitted
const { app, BrowserWindow, session } = require('electron');
app.whenReady().then(() => {
// A disk cache ceiling you chose, not one Chromium chose for you
app.commandLine.appendSwitch('disk-cache-size', String(64 * 1024 * 1024));
const win = new BrowserWindow({
show: false,
webPreferences: {
backgroundThrottling: true, // no rAF churn when hidden
spellcheck: false, // drops a dictionary + a renderer subsystem
contextIsolation: true,
},
});
win.once('ready-to-show', () => win.show());
});
// Kill renderers for windows the user actually closed
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
// Release blob URLs and caches when a heavy view goes away
function disposeHeavyView(contents) {
contents.session.clearCache(); // disk cache
contents.executeJavaScript(`
for (const url of window.__blobUrls ?? []) URL.revokeObjectURL(url);
`);
}
Two notes on the flags. disk-cache-size is read when a session is configured, so set it as early as you can — before the first window loads a URL, not after. And spellcheck: false is only free if you don't need it — for a chat client you probably do, so measure before you cut it.
Enforcing a memory budget in CI instead of discovering it in reviews
Electron exposes its own per-process metrics, and this is the piece most teams never wire up. app.getAppMetrics() returns a memory object per process, including workingSetSize in kilobytes:
// scripts/memory-budget.js — run in the main process after your test workload
const { app } = require('electron');
async function enforceBudget(budgetMB = 420) {
const metrics = await app.getAppMetrics();
const totalMB = metrics.reduce((sum, m) => sum + m.memory.workingSetSize, 0) / 1024;
if (totalMB > budgetMB) {
console.error(`memory budget exceeded: ${totalMB.toFixed(1)} MB > ${budgetMB} MB`);
for (const m of metrics) {
console.error(` ${m.type}#${m.pid} ${(m.memory.workingSetSize / 1024).toFixed(1)} MB`);
}
app.exit(1);
}
console.log(`memory budget ok: ${totalMB.toFixed(1)} MB`);
}
Worth knowing: workingSetSize is total working set, so it overcounts shared pages. That makes it an upper bound, and an upper bound is exactly what you want in a CI gate. Set the budget from your measured p95 plus 15% and let it fail the build. A budget nobody enforces is a comment.
A Fair Experiment: Comparing Two Clients Without Fooling Yourself
Controlled variables: same machine, same account, same server, same uptime
If you're going to reproduce a comparison, control these or don't publish:
- Same physical machine, same RAM total, same OS build.
- Same account, same guild list, same number of visible channels.
- Same cache state at launch — a warm cache is not a neutral starting condition.
- Same uptime points: 30 seconds, 5 minutes, 30 minutes. Not "after using it for a while".
- Same window visibility and focus state.
- Same measurement tool and counter on both sides.
Metrics worth capturing: private working set, total commit, startup, CPU idle
Four numbers, sampled on a schedule:
| Metric | How to get it | Why |
|---|---|---|
| Summed private working set | \Process V2(*)\Working Set - Private or PSS on Linux | Physical RAM cost |
| Summed commit charge | \Process V2(*)\Private Bytes | Reservation pressure |
| Cold start to first paint | Timestamp from process spawn to ready-to-show | Perceived speed |
| Idle CPU over 5 min | Get-Counter '\Process V2(*)\% Processor Time' | Battery, thermals |
Add peak working set too, since the first-paint spike is what triggers OOM on an 8GB machine.
Reading the results: what a 2x difference does and does not prove
A 2x difference in summed private working set — same machine, same uptime, same workload — proves the two builds have different baselines. That's a real result and worth publishing.
It does not prove:
- That one app scales better with 100 guilds loaded.
- That one app has a flatter slope over 24 hours.
- That one app uses less memory on a different OS.
- Anything at all about a build you didn't measure.
If the difference vanishes once you control for process count, you didn't measure a framework. You measured how many renderers each app left alive.
A Decision Framework for Leaving Chromium-Everywhere
When a native-shell rewrite is justified
- Installer size is a product constraint (enterprise deployment, kiosks, low-bandwidth distribution).
- Cold start is a product constraint and profiling shows runtime spawn dominates.
- You have genuine platform integration needs that Electron's IPC surface makes expensive.
- You have the headcount to maintain three webview targets and an accessibility test matrix.
- Your long-uptime problems are architectural — you already know where the memory goes and the fix requires replacing the shell.
When disciplined Electron is the cheaper answer
- Your memory slope comes from leaked listeners and timers, which the rewrite will re-implement badly.
- Your team is JS/TS-heavy and a Rust shell would create a second hiring profile for one client.
- Accessibility is a hard requirement and you don't have budget to build and test native-webview bridges.
- Your users sit on managed Windows fleets with unpredictable WebView2 runtime versions.
- The measured gap between your Electron baseline and a realistic webview baseline is under ~100MB of private working set.
The signals that you actually have a leak, not a framework problem
Test these before you open the rewrite epic:
- Process count grows over a 30-minute idle run with no user input.
- Private working set slope is positive while CPU idle sits near zero — memory growing with nothing happening.
- Killing a hidden window's renderer drops the total by a large step, meaning it held far more than a hidden window should.
- Heap snapshots show detached DOM nodes retained by your own closures, not by the renderer.
- Summed commit charge grows while summed private working set stays flat — you're reserving, not retaining.
Any two of those and the framework isn't your problem.
Conclusion: Measure the Working Set, Then Argue About Frameworks
The position this post takes
The reported 290MB figure is not evidence that Electron costs multiple gigabytes, because the two numbers were never measured the same way. A Rust rewrite with a system webview genuinely buys something real — no bundled Chromium, no Node main process, smaller installers, faster cold start — and that's a legitimate reason to do it. It does not buy a smaller DOM, a smaller decoded image cache, or a renderer that releases what it allocates.
If the rewrite is being framed internally as "we will fix memory", it will disappoint, because the memory problems in an always-on chat client live in the renderer's retained state, not in the shell. Move the shell if the shell is the constraint. Don't move the shell to avoid reading a heap snapshot.
The one measurement readers should run on your own app this week
Run this against your app: one 30-minute idle session, summed private working set sampled every 30 seconds, slope computed. If the slope is near zero and the baseline is high, your problem is your process model and your caches. If the slope is positive while the CPU is idle, your problem is your code, and no amount of Rust will fix it.
Everything else is a headline.
Further Reading
- Electron: Process Model — the official map of browser, GPU, utility, and renderer processes.
- Electron:
app.getAppMetrics()— per-process memory and CPU metrics, includingworkingSetSize, usable for CI budgets. - Microsoft: Working Set — the definition of resident versus private pages.
- Microsoft:
GetProcessMemoryInfo(psapi.h) — the underlying counters behindWorkingSet64andPrivateUsage. - Linux man-pages: proc(5) —
smaps_rollup, Pss, and Private_Dirty semantics. - Microsoft: WebView2 documentation — the Windows system-webview runtime that a Rust shell would embed.
- Tauri v2: Reducing app size — a practical look at what a webview-based shell does and does not ship.
More posts

Profiling an Electron App's Real Memory Hogs Before a Rust Rewrite
A hands-on walkthrough of how to instrument an Electron desktop app to find its true memory hogs before committing to a Rust rewrite.

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.

Benchmarking ERNIE 5.5 Flash Base: Separating Per-Token FLOPs From KV-Cache Bandwidth at 1M Context
A deep technical walkthrough of benchmarking ERNIE 5.5 Flash Base's 280B-parameter/14B-active MoE at 1M-token context, separating per-token FLOPs from KV-cache bandwidth costs.
Loading comments…