Porting a 7,600-Line Trading Sidecar from Node to Rust in One Sunday Session

Posted by Michael S. on May 4, 2026

The sidecar went from 98 MB to 5.6 MB. That's it. That's the headline.

Not that 98 MB was a real problem. This is a desktop trading app running on my own machine, not a Lambda function where every megabyte costs money. But 5.6 MB for a binary that handles live market feeds from four brokers, writes a custom recording format, runs a WebSocket server, and scrapes options data? That just feels right.


Why port at all

I built a trading workstation over the past year. Tauri shell (already Rust), React frontend with lightweight-charts, and a Node/TypeScript sidecar that does everything interesting: connects to E*TRADE, Schwab, IBKR, and Databento feeds, multiplexes quotes and trades over WebSocket to the UI, records sessions in a custom .tvr binary format, and scrapes max-pain data.

The sidecar is the hot path. Every tick from every broker flows through it. And V8's garbage collector puts a 1 to 10 millisecond tail on every dispatch. That's fine for a charting app. But I wanted to know if I could do better.

The answer was obvious once I looked at the repo honestly. The Tauri shell is already Rust. The upstream feeds are all WAN-bound (broker APIs over HTTPS and WebSocket), so C++'s kernel-bypass tricks buy nothing here. And Rust's ecosystem for this exact workload (tokio-tungstenite, reqwest, serde, hyper) is mature.


The conversation that started it

I asked Claude whether to go Rust or C++. It looked at the actual codebase, not the abstract question, and said something I already knew but hadn't admitted: "Your latency floor is the WAN, not the CPU. C++'s sub-microsecond tricks buy you nothing here."

Then it read through all 7,600 lines of the sidecar, mapped every feed handler and protocol path, and proposed an 8-stage incremental port where each stage ships independently with the Node sidecar as a fallback. No big-bang rewrite. Every stage produces a binary you can test against the existing UI.

I said "yes, and the rest" and "create a commit for every fix and keep going until it's done."


How it went

Eight stages, nine commits, one session.

Stage 0 set up the Cargo workspace alongside src-tauri/ (not nested under it, which would couple build graphs). Five crates: protocol, recording, feed-core, sidecar binary, and a bench harness. The WS and HTTP servers bound to ports but returned errors for everything. 2.4 MB binary out the gate.

Stage 1 was the synthetic feed. This is where things got interesting. The TypeScript sidecar uses a Mulberry32 PRNG seeded at startup to generate fake trades. The Rust port had to produce bit-identical output. JavaScript's Math.imul plus >>> 0 unsigned-right-shift semantics map to Rust's u32::wrapping_mul, but you have to get the golden vectors right or the integration tests fail. I captured the first 100 outputs from the TS version at seed 0xDEADBEEF and used them as a fixture.

Stage 2 ported the .tvr recording format. This was the most satisfying stage to write in Rust. The TypeScript version does per-tick heap allocation under GC pressure during a live session. The Rust version uses a pre-sized chunk buffer with tokio::sync::Mutex (replacing a promise-chain mutex pattern from the TS code) and flate2 for gzip compression on flush. A cross-implementation interop test verified that Node-written .tvr files replay correctly in Rust and vice versa.

Stage 3 added historical bar queries and the EOD-replay feed. Straightforward port.

Stage 4 was the one I worried about. E*TRADE's OAuth 1.0a requires HMAC-SHA1 signature base strings with pedantic percent-encoding (RFC 5849). The ~ character stays unencoded, + becomes %2B, parameters must be sorted. I captured a real signed request from the running Node sidecar and used it as a golden vector. If the Rust signature matches byte-for-byte, the encoding is correct.

Stages 5 and 6 ported Schwab's streaming API and IBKR's binary TWS protocol. The IBKR protocol is length-prefixed TCP frames with null-separated fields, which maps naturally to a tokio_util::codec::Decoder. Schwab uses server-sent field IDs that must be mapped to readable keys; keeping this in sync with the TS implementation was just careful table comparison.

Stage 7 brought up the HTTP routes for options scraping and the token-borrow mechanism.

Stage 8 deleted the Node sidecar. 21,000 lines of net deletion. The build-sidecar.sh script now only builds the Rust crate. tauri.conf.json didn't need a single edit because the output path is the same triple-suffixed binary Tauri's externalBin expects.


The numbers

Metric Node (Bun) Rust
Binary size 98 MB 5.6 MB
Cold start ~200ms (V8 init) Near-instant
GC tail latency 1-10 ms None
Test count ~40 TS 80 Rust + 3 TS golden-path

98 MB isn't bad for a self-hosted desktop app. It's Bun bundling its runtime plus all the dependencies into a single executable. Fine for what it is. But 5.6 MB for a release binary that does everything the 98 MB one did, with deterministic latency characteristics? I'll take it.


What I'd do differently

The whole port happened in one session. That's fast, but it means I leaned on mechanical translation more than I should have in a few places. The feed-handler trait hierarchy works but could be cleaner. The Schwab and IBKR feeds have the transport-injection scaffold but their production streaming loops aren't fully wired into the auto-selection logic yet. And the bench harness exists as a crate but I haven't actually run criterion benchmarks to get real p99 numbers.

Those are all follow-up items, not blockers. The app runs, the chart loads, the ticks flow.


The part nobody talks about

Porting 7,600 lines of TypeScript to Rust isn't really about Rust. It's about having a clear protocol boundary. The sidecar speaks a documented WebSocket protocol. The UI doesn't care what language the server is. If your architecture has a clean wire boundary between components, you can swap implementations without touching the other side.

The Tauri shell didn't change. tauri.conf.json didn't change. The React app didn't change. The build script got a new branch. That's it.

If your sidecar is a tangled mess of shared state with the main process, a rewrite in any language will hurt. If it's a separate binary that speaks a protocol, swapping languages is a build-system change.

Enjoyed this post?

Get notified when I publish something new. No spam, unsubscribe anytime.