Two exchange WebSockets at once killed my screen. Batching dispatch with requestAnimationFrame
How I batched prices arriving at hundreds of messages per second using a Map queue and rAF, and the alternatives I didn't pick.
#WebSocket #React #Redux #Performance #requestAnimationFrame
To see the kimchi premium, you need the Korean price and the overseas price at the same time. You get the won price from Upbit, get the dollar price from Binance, and calculate the difference. So I opened two WebSockets at the same time . Connecting them was easy. The problem came after. The Upbit ticker sends a message every time a trade goes through, and with around 200 coins subscribed, those come in at hundreds per second. Left the way I first built it, scrolling stuttered while the price table updated, and after a few minutes my laptop fan started spinning. This post is about how I fixed that. To give the conclusion first, I changed it so that instead of rendering on every message, it renders everything together just once per frame . The first code was one dispatch per message This is the most natural code to write. When a message arrives, parse it and put it straight into the store. At first it didn't occur to me that this could be a problem. ws.onmessage = (e) = { const coin = JSON.parse(e.data); dispatch(syncKRWPrice2({ code: coin.code, updatedCoin: coin })); }; The reducer is just one line too, so it looks harmless. syncKRWPrice2: (state, action) = { const { code, updatedCoin } = action.payload; state.coins[code] = updatedCoin; }, But when this runs 300 times a second , it's a different story. A single dispatch triggers all of it: the reducer run, the store update, and the re-render of the subscribed components. At 300 times a second, that's 300 re-renders a second as well. The screen can only draw 60 times a second. The other 240 were CPU spent calculating intermediate states that nobody ever sees . And on the main thread at that. Three approaches I considered I laid out three ways to fix it and compared them. Pros Cons 1. setInterval throttle Easiest to implement Out of step with the display (flush every 100ms) I can pick the interval Keeps running in a hidden tab 2. Move state down Redux load goes away 200 components means into components Subscriptions split up finely 200 subscriptions 3. Map queue + rAF Matches the display exactly One more layer of code (chosen) Stops by itself in a hidden tab Same symbol deduped automatically Number 1 is the easiest to reach for, but 100ms has nothing to do with the display refresh cycle (about 16.7ms). It drifts out of step, with some frames getting two updates and some getting none. Above all, it keeps running even while the user is looking at another tab . People often leave a price screen open and do something else, and it keeps eating battery the whole time. Number 2 does get at the root, but prices are used in several places besides the table, including the chart and the alert settings, so I would have had to turn the whole structure over. So I dropped it. I picked number 3. requestAnimationFrame is something the browser calls exactly once, right before it actually paints the screen . And when the tab goes to the background, the browser stops calling it on its own. There's nothing for me to take care of. The fixed code piles messages in a queue and empties it once per frame When a message arrives, it doesn't go into the store, it only gets piled up in a Map . Then on the next frame it's emptied in one go. const krwUpdateQueue = new Map(); let rafScheduled = false; const flushUpdates = () = { if (krwUpdateQueue.size 0) { const batch = {}; krwUpdateQueue.forEach((updatedCoin, code) = { batch[code] = updatedCoin; }); dispatch(syncKRWPriceBatch(batch)); // one dispatch krwUpdateQueue.clear(); } rafScheduled = false; }; const scheduleUpdate = () = { if (!rafScheduled) { rafScheduled = true; requestAnimationFrame(flushUpdates); } }; The key is the rafScheduled flag . Without it, 300 messages would schedule rAF 300 times. With the flag, exactly one callback gets scheduled per frame and the rest are blocked. The message handler now only puts the update in the queue and schedules a flush. krwUpdateQueue.set(coinSymbol, { krwSymbol: coinSymbol, krwprice: updatedCoins.trade_price, prevPrice: updatedCoins.prev_closing_price, change: updatedCoins.change, changePercent: updatedCoins.change_rate * 100, ... }); scheduleUpdate(); I changed the reducer too, so that it takes the whole batch instead of one coin at a time. syncKRWPriceBatch: (state, action) = { const updates = action.payload; for (const code in updates) { state.coins[code] = updates[code]; } }, Why I used a Map and not an array If I had made the queue an array, it would have turned out like this. // with an array - if the same coin arrives 5 times in one frame, all 5 pile up [BTC(97.00M), BTC(97.01M), BTC(97.00M), BTC(97.02M), BTC(97.01M)] -> the first 4 are values that get thrown away without ever showing on screen If you put the symbol into a Map as the key, only the last value is left no matter how many times the same coin arrives . The data structure does the deduplication for you. For actively traded coins like Bitcoin it's common to get several messages in one frame, so this alone cut down the data carried by each dispatch. A problem that came along as a bonus, the connection dies quietly Once the rendering was fixed, something else showed up. After leaving it on for a long while, the prices had simply stopped . No error, they just don't move. There were two causes. Upbit closes the connection if nothing is sent or received for about 120 seconds . And an exchange temporarily refuses connections when a lot of them come from the same IP in a short time (hammering refresh, for example). So I pulled the reconnecting part out into a separate socket wrapper. const scheduleReconnect = () = { if (disposed || retryTimer !== null) return; // 1s, 2s, 4s ... up to 30s. Half is fixed, half is jitter to spread out simultaneous reconnects. const base = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** Math.min(failures - 1, 5)); const delay = base / 2 + Math.random() * (base / 2); retryTimer = setTimeout(() = { retryTimer = null; open(); }, delay); }; I mixed in jitter because when several tabs drop at once and reconnect at exactly the same moment, they get refused again . Retrying at a fixed interval means making the refusals worse myself. I prevented the idle disconnect with a PING. const upbitConn = createReconnectingSocket({ url: 'wss://api.upbit.com/websocket/v1', onOpen: (ws) = { // the subscription has to be sent again on every reconnect for prices to flow again ws.send(JSON.stringify([ { ticket: 'kimchi-premium-check' }, { type: 'ticker', codes: coinNames }, { format: 'DEFAULT' }, ])); }, pingIntervalMs: 60_000, pingPayload: 'PING', }); What tripped me up the most here was sending the subscription again in onOpen . At first I only sent the subscription once when connecting, but after a reconnect the socket was alive with no subscription, so nothing came in. Connected but no data arriving is the worst state to debug. Not showing errors right away At first I showed a banner as soon as the connection dropped. What happened was that the banner kept flickering on the subway . Dropping for a moment and reconnecting is common, and announcing it every time made things look more worrying, not less. // Brief disconnects are common. Only tell the user after several consecutive failures. const FAILURES_BEFORE_WARNING = 3; const handleStatus = (label) = (connected, failures) = { if (connected) { onError(''); } else if (failures = FAILURES_BEFORE_WARNING) { onError(`${label} live price connection is unstable. Reconnecting automatically...`); } }; And for the Binance socket I made it show no banner at all. Binance is only used for the dollar price that goes into the kimchi premium calculation, so there's no reason for the user to know if it drops for a moment. Announcing every disconnect wasn't the kind thing to do . This is the screen now, after the fix. The Upbit and Binance sockets are both connected at the same time, and the connection status of the two exchanges is shown at the bottom right. The kimchi premium figures change in real time. Actually, this wasn't the first time While writing this post I dug through the git log, and the first time I added a socket to this project was July 22, 2024. There are five commit messages left from that day, in the order socket, test, error, error message, socket. I pulled this log out once before when I wrote about adding chat with Socket.IO, and it turns out the price socket started on the same day. With messages like test and error, I can't tell what I was doing even looking at it now. Back then I thought it was enough for the connection to work, and I didn't care at all whether the screen could keep up. So at the time I ripped the socket out and went back to REST polling. Then on November 12, 2025, I ripped the polling out again with a commit called delete: fetching api and change to socket , and that's where the problem in this post began. That was when the messages really started pouring in. I ended up back in the same spot after 1 year and 4 months, but this time the question wasn't whether it connects, it was whether the screen can keep up. It's still not completely done. With 200 coins on one screen, frames fall behind even with the rAF batching. Next is virtual scrolling. If only the rows actually visible on screen are drawn, the amount drawn per frame stays constant however many coins there are.