I wrote that price messages came in by the hundreds per second. When I counted, it was 36 on average
In my exchange WebSocket post I said I batched rendering with rAF, but there were no numbers at all. Replaying recorded real prices, there was almost no difference normally, and it only split into 7.6fps and 37.6fps when a slow phone met a busy market.
#Performance #WebSocket #React #requestAnimationFrame #Playwright
I was rereading my post about attaching two exchange WebSockets and killing the screen and got a little embarrassed. It says Upbit prices come in by the hundreds per second, and that I fixed it by batching what used to be one dispatch per message with requestAnimationFrame. But there is not a single measured number anywhere in the post. Not how many messages come per second, not how much better it got after batching. In my other posts lately I keep saying to measure before fixing, and this post did not do that. So this time I measured it again from scratch. To give away the ending, hundreds per second was wrong, and the effect of rAF batching turned out to look different from what I thought. First, how many messages actually come in I subscribed to every KRW market on Upbit and counted messages per second for 60 seconds. There were 295 KRW markets on the day I measured. markets 295 seconds 59 avg/s 35.8 median 33 max 79 min 15 max msgs in one 16ms window 175 top codes [ [ 'KRW-RSR', 301 ], [ 'KRW-USDT', 153 ], [ 'KRW-VVV', 105 ], [ 'KRW-BTC', 91 ], [ 'KRW-W', 85 ] ] 35.8 on average, and 79 even in the busiest second. Nowhere near hundreds per second. I measured on a weekday afternoon, so days when the market moves a lot will have more, but on a normal day I was off by an order of magnitude. Messages did not come evenly either. One coin sent 301 by itself, and Bitcoin sent 91. But there was one strange number. At one point 175 messages landed in a single 16ms slot. The average is 36 per second, yet 175 came in one frame. I took another 60 seconds the same way, and this time saved the arrival time of every message. recorded 2242 msgs first100ms 296 first1s 318 distinct 295 296 messages came in the first 100ms. There are 295 coins, so right after subscribing, it sends the current price of every coin once, all at once . After that it calms down to a little over 30 per second. What I described as a flood in post 77 might have been this first moment. It repeats every time you open the page or reconnect. How I measured If you measure against the real exchange, the prices are different every time, so you cannot compare. So I decided to use the 60 second recording from above as is. Replay the 2,242 messages at their recorded times, change only the receiving code between two versions, and compare them under the same conditions. One is the old way, one dispatch per message, and the other is the current way, which stacks messages in a Map queue and dispatches only once per frame. I did not fake the screen. I took the real site's store, sorting hook, and price table component as they are and had them draw 295 rows. // coinSite/bench/main.tsx const receive = (m: Msg) = { const coin = toKrw(m); if (mode === 'each') { store.dispatch(syncKRWPrice2({ code: coin.krwSymbol, updatedCoin: coin })); return; } queue.set(coin.krwSymbol, coin); if (!scheduled) { scheduled = true; requestAnimationFrame(flush); } }; There was one thing I was careful about in the replay. A real WebSocket delivers each message as a separate event. If the replay loop handled backed up messages all at once, the old way would get batched naturally and the comparison would mean nothing. So I sent each message through a MessageChannel so it is handled in its own task. With setTimeout the browser delays nested timers in 4ms steps, so I avoided that too. I attached React Profiler to count renders and render time, and at first everything came out 0. The production build of React turns Profiler off entirely. I fixed it by swapping react-dom/client for react-dom/profiling in the vite config. The browser was launched with playwright. I set the screen to 390 x 844, and ran separate cases with the CPU slowed down 4 times through CDP to mimic a phone. I also added cases that replay the recording at 5x speed, assuming a busy market day. Each condition ran twice and I took the average. await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }); await page.goto(`http://localhost:${port}/?mode=${mode} speed=${speed}`); await page.waitForFunction(() = window.__result); Results CPU speed mode fps p95 frame frames over 50ms renders render time 1x 1x each 58.6 16.8ms 5 2242 4,593ms 1x 1x raf 58.0 16.8ms 6 967 2,751ms 1x 5x each 56.6 16.8ms 6 2242 2,217ms 1x 5x raf 55.6 33.3ms 2 436 802ms 4x 1x each 54.6 16.8ms 34 2242 6,946ms 4x 1x raf 54.1 33.3ms 14 917 3,322ms 4x 5x each 7.6 292ms 98 2242 6,704ms 4x 5x raf 37.6 33.4ms 14 259 1,078ms The first thing you notice is that there is almost no difference normally . On a laptop CPU at real speed, the old way gets 58.6fps and the current way 58.0fps. Renders went from 2,242 to 967, and render time dropped from 4.6 seconds to 2.8, but that is not a difference you can feel on screen. It means that at 36 per second, the old way held up fine. The gap opens up once the CPU is slowed down 4 times. Even at real speed, frames that took over 50ms dropped from 34 to 14. And when a slow CPU meets 5x speed, the two split completely. The old way dropped to 7.6fps, and replaying the 12.3 second recording took 14.8 seconds. Messages were being handled slower than they came in, so they fell behind. That means prices showing up more than 2 seconds late. The current way got 37.6fps under the same conditions and finished the replay on time. So what I said in post 77 about the screen dying was true on a slow device during a busy market. But it was not always like that, and what rAF does on a normal day is closer to using less CPU than to saving the screen. One number I did not expect Looking at the table, one thing bugged me. The p95 frame time actually got worse with the current way. The old way is mostly 16.8ms, but the current way hits 33.3ms at 5x speed or on a slow CPU. That means 5 or more out of every 100 frames missed a frame. When you think about why, it makes sense. The old way renders a little every time a message arrives, so the work gets spread into the idle time between frames. The current way gathers that work and does it all at once inside the rAF callback, right before the screen is drawn . The total went down, but it is done in a lump at the busiest moment. If that one batch does not finish within 16ms, that frame slips. So batching was not free. When messages are few, spreading the work out is smoother frame by frame, and the more messages there are, the more batching wins by a wide margin. This site has to survive a busy market on a slow phone, so keeping the current way is right. It just was not the always better change that post 77 made it sound like. So what is next At the end of post 77 I wrote that with 200 coins on one screen, frames slip even with rAF batching, and that virtual scrolling was next. Back then it was a hunch, and this time I confirmed it with numbers. On a slow CPU at 5x speed, the current way is still at 37.6fps. Cutting down the number of renders by batching is already done as far as it goes, and what is left is how much gets drawn per render. Right now only part of the table is visible on screen, yet it draws all 295 rows. I left the benchmark in coinSite/bench . The recording and the runner are there together, so running npm run bench produces the same table again. Once I add virtual scrolling, I plan to measure again with the same recording and put the results next to this table. If you have a post saying you fixed some performance problem, I recommend checking whether you actually measured the numbers back then. My fix went in the right direction, but the number I gave as the reason for it was inflated about ten times. And when comparing before and after, recording real data and running both on the same input was the most reliable way. If you measure with different prices each time, you cannot tell what changed the numbers.