Most WebSocket codebases start the same way: one onmessage callback, a few if statementsโฆ and before long youโre staring at a 300โline switch statement handling trades, tickers, books, candles, heartbeats โ all in one place.
It worksโฆ until you need to test it, extend it, or debug it at 2am.
Thereโs a better pattern.
๐๐ก๐ ๐๐๐๐ฅ ๐๐ซ๐จ๐๐ฅ๐๐ฆ
WebSocket messages arrive as raw arrays or objects with minimal type information.
The naive approach couples routing and processing in one giant function.
That makes the code:
Hard to test
Hard to extend
Hard to debug
Easy to break
๐๐ก๐ ๐๐๐ง๐๐ฅ๐๐ซ ๐๐๐ญ๐ญ๐๐ซ๐ง
The idea is simple: separate routing from processing.
๐๏ธโฃ ๐๐ง๐ ๐ก๐๐ง๐๐ฅ๐๐ซ ๐ฉ๐๐ซ ๐๐ก๐๐ง๐ง๐๐ฅ
Each channel gets its own pure function โ no WebSocket context, no global state, no side effects beyond dispatching.
export const handleTradesData = (parsed, subscription, dispatch) => {
const currencyPair = subscription.request.symbol.slice(1)
if (Array.isArray(parsed[1])) {
const trades = parsed[1]
.sort((a, b) => b[1] - a[1])
.map(([id, ts, amount, price]) => ({ id, ts, amount, price }))
dispatch(tradesSnapshotReducer({ currencyPair, trades }))
} else {
const [id, ts, amount, price] = parsed[2]
dispatch(tradesUpdateReducer({ currencyPair, trade: { id, ts, amount, price } }))
}
}
Each handler has one job. Nothing more.
๐๏ธโฃ ๐ ๐ฅ๐จ๐จ๐ค๐ฎ๐ฉ ๐ฆ๐๐ฉ ๐๐ฌ ๐ญ๐ก๐ ๐ซ๐จ๐ฎ๐ญ๐๐ซ
The middleware doesnโt need to know what a โtradeโ or โcandleโ is โ it just picks the right handler.
export const handlers = {
TRADES: handleTradesData,
TICKER: handleTickerData,
BOOK: handleBookData,
CANDLES: handleCandlesData,
}
Adding a new channel becomes a twoโline change.
๐๏ธโฃ ๐ ๐ญ๐ก๐ข๐ง, ๐ฉ๐ซ๐๐๐ข๐๐ญ๐๐๐ฅ๐ ๐ฆ๐ข๐๐๐ฅ๐๐ฐ๐๐ซ๐
The middleware handles crossโcutting concerns (latency, stale detection, heartbeats) and delegates everything else.
const handler = handlers[subscription.channel]
if (!handler) return
handler(parsedData, subscription, store.dispatch)
๐๐ก๐ฒ ๐๐ก๐ข๐ฌ ๐๐๐ญ๐ญ๐๐ซ๐ฌ
Because now everything is testable in isolation:
it('handles trades snapshot', () => {
const dispatch = vi.fn()
handleTradesData(mockData, mockSubscription, dispatch)
expect(dispatch).toHaveBeenCalled()
})
No WebSocket server.
No Redux store.
No global state.
Just a function.
๐๐ก๐ ๐๐๐ค๐๐๐ฐ๐๐ฒ
This pattern is the Single Responsibility Principle applied at the architecture level:
Middleware decides when to process
Handlers decide how to process
Adding features becomes additive, not destructive
Debugging becomes targeted, not chaotic
This is the difference between code that works and code that scales.
๐ช๐ฟ๐ถ๐๐๐ฒ๐ป ๐ฏ๐ ๐ฅ๐ถ๐ฐ๐ฎ๐ฟ๐ฑ๐ผ ๐ฆ๐ฎ๐๐บ๐ฒ๐๐ต
๐ฆ๐ฒ๐ป๐ถ๐ผ๐ฟ ๐๐ฟ๐ผ๐ป๐โ๐๐ป๐ฑ ๐๐ป๐ด๐ถ๐ป๐ฒ๐ฒ๐ฟ | ๐ฅ๐ฒ๐ฎ๐นโ๐ง๐ถ๐บ๐ฒ ๐จ๐ ๐ฆ๐ฝ๐ฒ๐ฐ๐ถ๐ฎ๐น๐ถ๐๐

Top comments (0)