electron-to-rust-4gb-mbox+

I rewrote my MBOX converter in Rust. But Rust wasn't where the speedup should have started

● 2026-09-10 · 15 min readdraft

Almost three and a half minutes turned into a few seconds. A great result for the user — and an all-too-convenient excuse to declare one language the winner over another. I decided to check how much of the speedup actually required a rewrite.

Where the converter came from

In 2016 I needed to export my Gmail correspondence about hiring developers and turn it into a spreadsheet I could analyze in Excel. I wrote a small MBOX → CSV converter in Electron and Node.js. It read the mail archive, pulled out the headers and message text, and wrote rows to a CSV.

Not especially elegant, but it did the job. In April 2016 the app appeared in the Mac App Store. The last old version, 1.3.2, came out in February 2019.

Years later I came back to the project and moved the engine to Rust, since with agents that can be done quickly and efficiently.

For testing I took a real Gmail export: 4.78 GB, 12,428 messages.

In a series of measurements the old pipeline processed it in a median 197.05 seconds, parallel Rust (--threads 8, measured in a later separate series) in 1.91 seconds. A difference of 103.2×.

I could have stopped there: “rewrote it in Rust — got almost a 100× speedup.”

But the language wasn’t the only thing that changed between the programs. The old runtime gave way to a new one, x64 via Rosetta gave way to native arm64, and the MIME parser, flow control and data extraction rules all changed. How much of the result would have remained if I had stayed on JavaScript altogether?

First I had to change the stopwatch

The first version of this article relied on measurements in which the programs were timed differently. Node reported its own internal time, Rust was measured from outside. For Node, memory was polled inside the event loop; for Rust, the OS maximum was taken in a separate run.

That was enough to spot a huge gap. It was not enough for a careful comparison of the intermediate steps.

So on September 14, 2026 I ran a new campaign with a single external observer. All the main timings and RSS values below come from it. I don’t mix the old charts and figures with the new ones.

Test machine: a MacBook Pro with an Apple M4 Pro, 12 cores, 24 GB of memory, macOS 26.6.2, internal SSD. One input file, CSV with a comma as the delimiter.

The observer waits for the launched process to finish and, roughly every 100 ms, polls the RSS of the whole process tree via ps. A technical detail: the clock starts right after the process is created and the sampler is started, not before Popen. This is a single external measurement, but not a precise measurement of the cost of the launch system call itself.

The four main variants went through five rounds with interleaving. After that, five runs of a control variant on plain Node were added separately — 25 runs in total. This difference in ordering matters for the Node vs. Electron comparison.

The file cache was not flushed, and there was no separate warm-up. Interleaving reduces sensitivity to slow load drift, but doesn’t guarantee identical cache conditions.

One more limitation: what’s measured here is conversion without a user window, not application responsiveness. Electron runs in the main process without a window, Rust via the CLI. The memory of Electron’s helper processes is included in its RSS; the memory of an open Tauri interface is not measured.

Not two languages, but several specific programs

In the test setup the variants are labelled with version numbers. These are experiment labels, not a list of published releases.

Variant What runs
1.3.2 Old code: node-mbox 1.0.0 + mailparser 2.4.3, Node 8.1.3 x64 via Rosetta
1.3.3-electron The same pipeline and library versions, Electron 44.3.0 arm64, a streams compatibility adapter
1.3.3-node Control: the same adapted old pipeline on Node 24.14.1 arm64
1.3.4 Node 24.14.1, postal-mime 3.0.0, a custom byte splitter, bounded processing and HTML → text
2.0.0 The sequential Rust pipeline from the main campaign, release build, mail-parser 0.11.9

That last clarification is essential: after the main campaign, Rust gained a parser pool, enabled by default. Its results come separately. The 5.66 s figure describes the sequential variant measured at the time, not the current automatic thread-selection mode.

Here is the whole main table, so the result isn’t hidden behind the investigation:

Variant Median, s Min–max, s RSS¹, MB
1.3.2 197.05 195.18–208.53 3238
1.3.3-electron 72.51 71.68–77.57 2872
1.3.3-node² 76.80 75.49–85.17 2804
1.3.4 47.49 46.59–49.23 1195
2.0.0, sequential 5.66 5.51–6.06 408

¹ Median of the observed maxima of the summed RSS across the tree, over five runs; decimal MB. These are discrete samples, not the OS maximum. Summed RSS is also not equal to unique physical memory: shared pages can be counted under several processes.

² An additional series after the main rounds, not an interleaved A/B with Electron.

The first win: leave the logic alone, update the environment

The most obvious objection to the comparison with Rust: the old code was running in an environment almost a decade old.

The app’s historical lockfile has Electron 2.0.12. For the headless setup I used Node 8.1.3, pinned in the project itself. This is not a claim about the exact Node version inside the old Electron. The old app was x64-only, so on this machine Rosetta matches its historical way of running.

In the first campaign the old pipeline wouldn’t run on modern Node at all: the old streams libraries assign this.errored, and the modern Readable no longer allows that write.

In the second campaign a small compatibility adapter appeared. It substitutes stream subclasses without rewriting the MIME parsing. That made it possible to keep the old pipeline and its dependency versions, but run them on a modern Electron.

197.05 → 72.51 s. A 2.7× speedup before rewriting the engine.

This is the result of updating the execution environment as a whole: the runtime, the CPU architecture, no Rosetta, the compatibility adapter. The individual contribution of V8 or of translation can’t be separated out of it.

But there is already a practical conclusion: if the goal was only to cut the wait, this experiment is where I should have started. The old logic didn’t have to be ported to another language to remove most of the waiting time.

What if Electron is still what’s slow?

To check, the same adapted code was run on plain Node 24.14.1.

The result was 76.80 s versus 72.51 s for Electron. In this test no Electron penalty on conversion is visible.

This is not proof that Electron is faster than Node in general. The bundled Node and V8 versions differ, and the control series ran as a separate block. Attributing the small difference specifically to V8 would take a different experiment.

What it does show is that the explanation “everything is slow because of Electron” doesn’t help here. Removing the shell for the sake of MIME parsing speed turned out not to be the step to start with.

The second win: streaming doesn’t yet mean bounded memory

The old converter already read the file as a stream. The story “JavaScript loaded the whole mailbox, Rust started reading it in chunks” would be convenient, but wrong.

The difference began after a message boundary was found. Each message event kicked off an asynchronous parse. The source didn’t wait for the handler to finish and didn’t limit the number of messages in flight. Writing the CSV also didn’t pause the source when the output buffer filled up.

In other words, the reading is streamed, but the amount of unfinished work is unbounded.

The modern JS variant gained its own byte splitter, a bounded processing window, and waiting for drain on writes. In the measured configuration postal-mime processes one message at a time; there are no worker threads here. Rows are written in the order of the input file.

And I had to add some work that’s easy to forget in a benchmark: HTML → text. On its own, postal-mime doesn’t synthesize plain text from HTML. If you take only the ready-made text field, some HTML messages will end up with an empty Body Text. A variant like that can’t be passed off as an equivalent converter just because it’s faster.

So the measured Node pipeline included a port of HTML → text from the Rust version.

The result relative to the old pipeline on modern Electron:

  • time: 72.51 → 47.49 s, another 1.5×;
  • observed maximum RSS: 2872 → 1195 MB.

Memory changed more noticeably than time. That’s consistent with how the pipelines are built: bounding unfinished work is useful. But the whole gain can’t be attributed to backpressure alone — the parser, the splitter, body handling and the runtime (Electron to Node) all changed at the same time.

That’s why this step is called “a new JS pipeline”, and not “we only swapped the library.”

What was left for Rust

After updating the environment and reworking the JS pipeline, what remains is 47.49 s for Node versus 5.66 s for sequential Rust. A difference of 8.4×; by observed RSS — 1195 versus 408 MB.

It’s convenient to write out the whole path like this:

197.05 s  — old pipeline in the old environment
    ↓ ×2.7  execution environment update
 72.51 s  — old pipeline on modern Electron
    ↓ ×1.5  new JS pipeline
 47.49 s  — Node + postal-mime + HTML→text
    ↓ ×8.4  a different implementation in Rust
  5.66 s  — sequential Rust

The ratios of adjacent timings really do multiply into the overall ratio — up to rounding. But that’s arithmetic, not a decomposition into independent causes.

The remaining 8.4× did not become “the pure price of JavaScript.” Different MIME libraries, data representations, serialization and text-selection rules haven’t gone anywhere. We’re comparing programs with the same purpose, not one algorithm in two syntaxes.

And Rust’s memory isn’t magically a few megabytes. It holds an individual message in full, and this mailbox contains a message 35.84 MB in size. Decoded parts and the output strings are added on top of the raw bytes. The streaming pipeline doesn’t hold the whole mailbox, but that’s no promise of the same RSS for any messages.

Check not only the stopwatch, but the CSV too

In all variants the schema is the same — nine columns:

Date Time, From Name, From Email, To Name, To Email,
Reply-To, Subject, Message Id, Body Text

For the user, the contract is simpler than the internals: don’t skip a large message, don’t truncate a long body, and don’t mix the contents of attached files into the text. If there’s no non-empty plain text, extract text from the HTML.

And here, checking the code corrected my own memory. At first I believed the body had been disabled in the old version. No: 1.3.2 read mailObject.text and wrote it to the CSV. What’s more, a fix release for HTML → text had already shipped. Incomplete extraction can’t be explained by a disabled option.

During the migration, regressions in the Rust version also turned up.

Split headers. A sender can cut an encoded-word in the middle of a multibyte character. If you decode the pieces separately, U+FFFD replacement characters appear instead of the letter. In a synthetic test this was the word “Ваш”, which turned into В<U+FFFD><U+FFFD>ш.

The fix is to normalize adjacent encoded-words at the byte level before MIME parsing, and leave the decoding itself to the library. Only the header block is processed, not the message body.

Recipient name. If the display name was missing or merely repeated the email, Rust also wrote the address into To Name. Now the name stays empty and the address goes in its own column. This is a restoration of expected behaviour, not a reason to claim the new version “extracts more data.”

There are also intentional differences:

Field Decision in Rust
Date Time YYYY-MM-DD HH:MM:SS, the sender’s clock from the header, with no conversion to the machine’s local zone
Message Id Without angle brackets
Body Text from HTML Custom HTML → text; whitespace and line breaks may differ

Individual checks can’t be turned into a promise of full equivalence. An equal row count doesn’t prove identical content, and a larger CSV doesn’t by itself prove better extraction.

Back when I was preparing the first version of the article, I made a set of 12 synthetic messages: encodings, attachments, HTML, a missing date. Between Node with postal-mime + HTML→text and the Rust of that time, after normalizing line breaks, all fields matched in 11 out of 12 cases.

The remaining case was whitespace-only plain text alongside meaningful HTML. Node accepted the whitespace string as a ready body, Rust fell through to the HTML. This is a historical check, not a re-test of the current build. But it explains well why I don’t write “the output is now completely identical.”

No personal mail goes into the examples: only aggregates and artificial messages.

Five runs — five different files

In the new campaign a SHA-256 was saved for every CSV. That produced one more result that isn’t visible in the timing table.

Pipeline Distinct SHA-256 over five runs
1.3.2 5
1.3.3-electron 5
1.3.3-node 5
1.3.4 1
2.0.0, sequential 1

For the old pipelines, the file size within each variant didn’t change, but the bytes did.

The investigation found two causes. Rows are written as they become ready, not in the order of messages in the MBOX. On top of that, mailparser 2.x inserts the current time into the Date: of an embedded message if it has no date of its own.

These are different problems. Sorting the rows alone can’t remove the dependence of the content on the clock.

For the new Node and Rust variants, repeated runs of each produce an identical file. Not identical between Node and Rust, but reproducible within its own implementation.

One question remained: are we buying reproducible order at the cost of speed?

Now let’s look inside Rust

After comparing whole programs, I measured sequential Rust stage by stage on the same mailbox. A separate pipeline_stages runs variants with progressively added work:

Stage Added time
Reading the MBOX and splitting it into messages 1.38 s
Parsing 3.68 s
Building and writing the CSV about 0.18 s

The full variant of this experiment took 5.23 s. This is not a new result from the main five-run series: it’s a different measurement mode. The increments were obtained by comparing variants, not with a CPU profiler.

But for the next decision the precision is sufficient: about 70% of the time goes to parsing, about 26% to reading and splitting.

The messages are independent. So there was now a concrete reason to write a parser pool: keep reading sequential, distribute MIME parsing across threads, and reassemble the finished rows in mailbox order.

Not “let’s add parallelism because the CPU is multicore,” but “here’s the stage that makes it worth doing.”

The pool, ordering, and the cost in memory

In a separate experiment — medians of three runs per setting:

Parser threads Time, s Peak RSS from the report, MB
1 5.56 394
2 3.06 563
4 1.88 632
6 1.63 —
8 1.59 652
12 1.58 —
8, writing without preserving order 1.60 641

This table is taken from the experiment’s report. Its raw repetitions are not among the 25 records of the main campaign, and the RSS methodology isn’t described separately; dashes mean no reported measurement. So I don’t use the memory here for new ratios against all the JS variants.

Going from one to eight threads gave 3.5×. Beyond that there’s almost no gain: 1.59 s on eight versus 1.58 s on twelve. The cost of parallelism in the reported RSS values is 394 → 652 MB.

And the most interesting result is in the last row. Without preserving order: 1.60 s. With it: 1.59 s. On this test, giving up ordering didn’t yield a noticeable speedup.

This doesn’t mean reordering is always free. But here I didn’t have to choose between a fast CSV and a reproducible one. With one and eight threads, with order preserved, the SHA-256 matched.

So the pool is enabled by default in the current code: one thread per available core, no more than eight. The sequential mode remains. On this machine, separate checks of automatic selection gave 1.68–1.85 s, with the same result.

I don’t substitute 1.59 s for 5.66 s in the main table, and I don’t assemble a new flashy multiplier from different series. The main campaign compared specific builds. The later experiment improved the product — and should stay a separate experiment.

Where moving off Electron does show up right away

The shell has another measurable cost — the size of what ships.

In a separate measurement on September 11, still for the first version of the article, I built two universal packages without signing or notarization:

Package On disk
Empty Electron 44.3.0 app, just a window 505.0 MB
The full converter on Tauri 2 20.1 MB

This is an argument about the size of these two packages. Not about parsing speed, not about interface memory, and not about the download size from the App Store.

So moving to Tauri makes sense for the product regardless of whether Electron slows down this particular MIME parsing. It’s just not worth mixing these reasons into one “Electron vs. Rust” number.

What I would do differently now

If I were starting this investigation over, I would first update the execution environment of the old code. Then I’d check a modern JS pipeline with the required text extraction. And only after that would I decide whether the remaining difference justifies moving to Rust.

This doesn’t cancel out the result of the migration. 197.05 → 5.66 s in the main campaign is a real reduction in waiting time. Separate work on Rust then gave 5.56 → 1.59 s while preserving the output bytes. The product got faster, not just better suited to a flashy headline.

But the most useful findings don’t boil down to a language’s victory:

  • Old code can be sped up noticeably without rewriting its logic.
  • Streaming reads don’t automatically bound the amount of unfinished work.
  • A fast parser has to be checked together with what it put into the CSV.
  • In this experiment, row order could be preserved without a noticeable loss of speed.
  • What was worth parallelizing was a specific measured stage — and the memory cost should be shown openly.

Why the modern Node pipeline still lags behind sequential Rust by 8.4× is something this campaign doesn’t fully explain. That would take profiles of allocations, decoding, copying and serialization. Dividing one file’s processing time by another’s is no substitute for such a profile.

But it’s now clear which steps helped this particular product. And the main lesson for me turned out not to be “rewrite everything in Rust,” but something simpler:

Before rewriting a program, it’s worth checking how much of its slowness we already know how to remove without a rewrite. And afterwards — making sure the fast result is still correct.


App: MBOX to CSV Converter.

The cover is a conceptual AI illustration created via OpenRouter with the Gemini 3.1 Flash Lite Image model. It is not a screenshot or a measurement chart; no personal mail was used to create it.