Redis pub/sub as a tick bus — and when not to
Why KAI Terminal moves live market ticks over Redis pub/sub instead of a bespoke in-process channel, the latency reasoning behind it, and the trade-off that comes with it.
A trading terminal lives and dies on its tick path: a price update arrives from the exchange feed and has to reach two consumers fast — the risk engine that might decide to exit a position, and the browser watching a number tick. The question is what carries the tick between the producer and those consumers.
The obvious answer in a single .NET process is an in-memory channel. We didn't do that. The tick bus is Redis pub/sub. Here's the reasoning, because the default instinct ("don't add a network hop to your latency-critical path") is exactly the thing worth examining.
The shape
There's one abstraction at the center — call it ITickBus — with a single
producer and several subscribers. The market-data feed publishes each tick to a
channel and, alongside it, writes a per-instrument snapshot:
feed ──publish──▶ channel: ticks ──▶ risk engine
└─write──▶ ltp:{instrument} (snap) ──▶ SignalR fan-out ──▶ browser
└─▶ late joiner reads snapshot
The snapshot matters: pub/sub only delivers to whoever is subscribed right now,
so a consumer that joins late (a page that just loaded) would miss everything
until the next tick. The ltp:{instrument} snapshot gives late joiners an
immediate value, then the live stream takes over.
"But you added a network hop"
Yes. And it doesn't matter, because of where the time actually goes.
The latency-critical path isn't tick-to-screen, it's tick → evaluate rule → place the exit order at the broker. Walk that path and time each leg:
- Redis hop: sub-millisecond on the same host.
- Rule evaluation: a pure function over a handful of numbers — negligible.
- Broker order round-trip: tens to hundreds of milliseconds over the public internet to the broker's API.
The broker round-trip dominates by two to three orders of magnitude. Shaving the sub-millisecond Redis hop down to an in-process channel call would optimise a rounding error while the real cost — the order leaving your building — sits untouched. Optimising the part that doesn't matter is how you end up with a clever, fragile system that isn't actually faster.
So the right question wasn't "what's the lowest-latency transport?" It was "given the broker round-trip dominates, what transport buys me the most operationally for free?" That reframes everything.
What the hop buys back
Once you accept the Redis hop is noise, it starts paying rent:
Independent scaling. The market-data feed and the web tier are decoupled. The feed is a single app-scoped connection (one analytics token serving everyone, not one socket per user); web instances subscribe to the same channel. You can add web capacity without touching the feed, and the feed process can be moved or restarted on its own.
A proven primitive instead of a bespoke one. A hand-rolled in-process fan-out with backpressure, late-join handling, and multiple consumers is real code with real concurrency bugs waiting in it. Redis pub/sub is battle-tested and the snapshot pattern is a few lines. Less of our code to get wrong.
A natural seam. Because the bus is an interface, the producer side is pluggable — the feed can run as a live WebSocket or a polling fallback, selected by config, and nothing downstream knows or cares.
The trade-off, stated honestly
This isn't free. Redis becomes a hard dependency for live data. No design note is complete without naming what it costs.
We soften the failure mode rather than pretend it away: if Redis is down, the bus no-ops instead of throwing, so the app still boots and the rest of the product (auth, historical views, account screens) keeps working — you just don't get live ticks. That's a deliberate "degrade, don't crash" choice. Live data is important, but it shouldn't take the whole terminal down with it.
There's a second, sharper caveat that the same architecture forces you to confront: with pub/sub fan-out, running two instances of the risk engine would double-fire actions. Two engines, both subscribed, both deciding to roll the same strike. Exits happen to be idempotent, but rolls are not. So the engine is deliberately single-instance today; true multi-instance would need leader election. Writing the transport down as a bus made that failure mode obvious up front instead of a surprise in production — which is itself an argument for the design.
When not to do this
I'd give the opposite advice in a heartbeat for a different system. If you have a genuinely single-process app with one producer and one consumer, no late-join problem, and no plan to scale the tiers independently — an in-process channel is simpler and you should use it. Adding Redis there is cargo-culting.
The decision turned entirely on one measured fact: the broker round-trip dominates the path that matters. Measure your own dominant cost first. If the network hop you're worried about is actually your bottleneck, this whole article argues the other way.