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.
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
| Item | Per 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 trades | 60 ms median, 236 ms at p95 |
| A calculation reaches the database | 36 ms median, 161 ms at p95 |
| The database write itself | 11 ms median |
| Dashboard's main read, 61 KB of JSON | 24 ms median |
| Re-checking maximum drawdown | 3,210 accounts in 6 seconds, 1.9 ms each |
| Requests rejected | 0.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.
| TypeScript | Rust, 1 thread | Rust, 10 threads | |
|---|---|---|---|
| 100,000 accounts | 9,807 ms | 275 ms | 37 ms |
| Per account | 98 us | 2.75 us | 0.37 us |
| Peak memory | 2,583 MB | 535 MB | 536 MB |
35.6x on one thread, and a fifth of the memory. Now the part I almost did not check.
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 core | 1.28 billion trades |
| Rule engine alone, Rust, one core | 45.6 billion trades |
| Whole pipeline on one laptop | about 225 million trades |
| My production at normal load, not its ceiling | about 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.
