Notes from the build

Scaling a Node.js trading system to six workers on Redis, for $126 a month

9 min read

One Node.js service for trades, rules, sockets and email became six workers behind one Redis and BullMQ. Here is what it costs, what it handles, where it breaks, and what happened when I rewrote the rule engine in Rust.

ArchitectureRedisRustPerformanceInfrastructure cost

The first time I saw the problem, about a thousand people were checking trading accounts by hand. One person opens an account, reads the trades, decides whether a rule was broken, moves to the next one. Millions of accounts, one pair of eyes at a time.

I wrote one backend server to replace them. It took the trades in, ran the executions, held the detection socket open, decided whether a rule had been breached, and emailed the trader. TypeScript on Node, because that is what gets you to an answer fastest, and the only answer I needed was whether a program could reach the same verdict as a person. It could. The desk emptied.

Then the users arrived, and the next two years were me taking that server apart, one piece at a time, every time something broke in a way I could not patch.

Redis was just a cache to me

I built the first version without it and the database ate RAM. Every time I gave it more it found a use for it. What actually stopped me was not memory, it was the connection limit. A hundred thousand users make a lot of simultaneous reads, and MySQL refuses new connections long before it runs out of anything else. That is the wall people hit first and understand last, because every dashboard says the box is fine.

So I put Redis in front of it. What that bought me was not speed, it was a small database. I stopped paying for a large MySQL instance and bought the RAM on Redis instead, where a gigabyte does far more work.

That was the first time I made something cheaper by moving it rather than by optimising it.

Then everything started competing with everything

At a hundred thousand users my single server stopped being an architecture and became a queue with no queue.

Trades arriving competed with rule calculations. Calculations competed with the socket pushing numbers to the dashboard. The socket competed with the mailer, and the mailer is the slowest thing in the building because it sits waiting on somebody else's server. One slow email provider could hold up a trade check.

And I could not see any of it. That is the part I want to be honest about. One process hides which part is expensive. The box is busy, and that is all you get.

So I broke it into six, with one Redis in the middle and a job queue carrying work between them.

terminalsintakemetricsstreamdatabasemaildelivery60 ms to answer24 ms dashboard read36 ms to the row

Each one has a single job. Intake receives the raw trades and does nothing clever, because it is under the most pressure and it should stay boring. Metrics is the brain: it turns trades into rule-wise calculations and decides whether an account is running, breached or passed. Stream pushes that to the dashboard. Database writes the same thing down. Mail tells the trader. Delivery issues the next account and tracks the awkward states, like passed-but-unpaid, or failed-but-issued-anyway, which is the one that costs you money if you get it wrong.

Stream and database run at the same time, deliberately. A socket is the most fragile thing in the system: a laptop closes, a phone goes into a lift, and the stream is gone. So the stream carries the number for the person watching and the database carries it for the record. A dashboard that reconnects pulls what it missed and nobody notices.

The last move was the cheapest one. I put the database, the cache and every worker in the same region. Every hop between them gets paid for twice when it crosses a region: once in latency, once on the invoice.

What I pay for it

ItemPer month
MySQL, 4 GB and 2 vCPU$48.00
MySQL storage, 60 GiB$12.90
Redis, 2 GB and 1 vCPU$30.00
Five services at 1 vCPU and 0.5 GB$25.00
One worker at 1 vCPU and 1 GB$10.00
All of it$125.90

$4.20 a day, and it does not move when the trade count does. I started out paying roughly $100 to $200 per hundred thousand executions, so the real change is not that the number got smaller. It stopped being multiplied by anything.

And look at the split before the total. The six services doing all the work cost $35 between them. The database and the cache cost $90.90. The compute is the cheap part. I spent two years assuming the opposite.

What it actually does

These come from the services' own duration fields, not from a stopwatch I held.

Measured
Intake answers a batch of trades60 ms median, 236 ms at p95
A calculation reaches the database36 ms median, 161 ms at p95
The database write itself11 ms median
Dashboard's main read, 61 KB of JSON24 ms median
Re-checking maximum drawdown3,210 accounts in 6 seconds, 1.9 ms each
Requests rejected0.033%, one in 3,029

I rebuilt it on my laptop so I could break it

Those numbers tell me how it behaves. They do not tell me where it stops, and you do not find that out on a system judging real traders' accounts. So I ran the real services against a local Redis and MySQL, generated trades in the same shape as the real ones, and turned the dial up.

The first thing I found had nothing to do with speed. Each terminal can only enqueue four jobs a second, because the job id is the account number plus the current quarter second. I offered the system everything from 50 to 5,800 batches a second and the engine received about 40 jobs a second at every single rate. Flat, across a 116x range.

Nothing is lost, every batch carries the full history. But it means something I had never said out loud: my engine's load scales with how many accounts I have, not with how fast they report. The queue starts backing up at around 310 accounts all reporting flat out.

The second thing I found was where it breaks. Intake goes first, single threaded, pinned at 115% of a core, while the rule engine sits at 3% and no queue is deeper than nine jobs. I profiled it while it was drowning. The time was going to parsing JSON and collecting garbage, not to my rules.

So I rewrote the rule engine in Rust

Same rules, same data, 100,000 accounts and 3.49 million trades. Before I timed anything I made both versions produce byte-identical verdicts, on four seeds, two of which I picked after the port was finished.

TypeScriptRust, 1 threadRust, 10 threads
100,000 accounts9,807 ms275 ms37 ms
Per account98 us2.75 us0.37 us
Peak memory2,583 MB535 MB536 MB
TIME PER ACCOUNTTypeScript98 usRust2.75 usPEAK MEMORYTypeScript2,583 MBRust535 MB

35.6x on one thread, and a fifth of the memory. Now the part I almost did not check.

WHERE THE 9,807 MS GOESTypeScript4,930 ms4,693 ms9,807Rust275 msDates kept as strings. 50.3%, and fixable in TypeScriptRe-serialising JSON and object traversal. 1.9%, also fixable in TypeScriptEverything else, the language and runtime includedWhat the whole Rust rewrite takes, start to finish

That is not a language comparison, and I am not going to sell it as one. Half the gap is that my original kept dates as "2026-10-01" strings. Some of the rest is re-serialising JSON it had just parsed, and holding one object per record. Every one of those was available to me in TypeScript the whole time. I just never wrote it.

So the true sentence is: a rewrite was worth 35.6x, and half of it was never about Rust. Half the cost of my rule engine is turning dates into strings and back, and I can fix that this week without changing language.

I had seen this before and not believed it hard enough. In a benchmark I keep, changing the storage format from JSON objects to a flat binary buffer took one thread from 62,556 to 4,857,759 accounts a second. That is 78x, inside JavaScript. Rust on the same algorithm was worth 2.06x.

What all that buys

Per hour
Rule engine alone, TypeScript, one core1.28 billion trades
Rule engine alone, Rust, one core45.6 billion trades
Whole pipeline on one laptopabout 225 million trades
My production at normal load, not its ceilingabout 23 million trades

My rule engine was never the constraint. It could do a billion trades an hour on one core in the language it is already written in. The system does 23 million, and everything in between is queues, hops, sockets and writes.

What I would tell myself two years ago

One process hides the cost. Split the work and each part has to admit what it uses.

Buy RAM where it earns the most. The connection limit will stop you before the memory does.

Put the database, the cache and the workers in one region before optimising anything else. Distance shows up on the clock and on the invoice.

Measure before you rewrite. A trade costs nanoseconds to evaluate and my system answers in milliseconds, so nearly all of it is waiting, and no language fixes waiting.

Then look at your data before your runtime. Mine was worth 78x in one measurement and half the engine in another. The language was worth 2x.

And benchmark your own side as hard as the side you want to win. My first Rust reader measured slower than Node until I found a bounds check per byte. My first tail latencies were measured cold, which flattered Rust by five times. The 35.6x above got marked unfair by my own review before it reached this page, because the port had quietly changed the data layout.

Three times, on the same piece of work, I nearly published something that was not true.