Performance Optimization for Real-Time Streams
High-frequency streams test React's rendering capacity. A stock ticker receiving 100 updates per second can cause jank (dropped frames, stuttering) if each update triggers a render. Batching updates, throttling renders, and managing subscriptions prevent garbage collection pauses. The goal: maintain 60 fps even with thousands of messages per minute (Crater Labs, 2026).
Problem: Rendering Thrash
Each WebSocket message calls setMessages([...messages, newMsg]). React re-renders, reconciles the DOM, and paints. For 100 messages/second, that's 100 renders/second—impossible on a 60 Hz screen. Result: frame drops, perceived lag, battery drain on mobile.
Measurement: Use React DevTools Profiler to see render times. If a single render takes > 16.7ms (1000ms / 60fps), you're dropping frames.
Approach 1: Batching Updates
Collect updates for 100ms, then apply all at once:
import { useEffect, useRef, useState } from 'react';
export function useBatchedMessages(onBatch) {
const batchRef = useRef([]);
const flushTimerRef = useRef(null);
const flush = () => {
if (batchRef.current.length === 0) return;
onBatch(batchRef.current);
batchRef.current = [];
flushTimerRef.current = null;
};
const add = (message) => {
batchRef.current.push(message);
// Flush immediately if batch size exceeds threshold
if (batchRef.current.length >= 50) {
flush();
} else if (!flushTimerRef.current) {
// Or flush after 100ms
flushTimerRef.current = setTimeout(flush, 100);
}
};
useEffect(() => {
return () => {
if (flushTimerRef.current) clearTimeout(flushTimerRef.current);
flush(); // Flush remaining on unmount
};
}, []);
return add;
}
export function PerformantStockTicker() {
const [prices, setPrices] = useState({});
const addBatch = useBatchedMessages((batch) => {
setPrices((prev) => {
const updated = { ...prev };
batch.forEach((msg) => {
updated[msg.ticker] = msg.price;
});
return updated;
});
});
useWebSocket('wss://your-server.example.com/prices', {
onMessage: (msg) => {
if (msg.type === 'price-update') {
addBatch(msg);
}
},
});
return (
`<div>`
{Object.entries(prices).map(([ticker, price]) => (
`<div key={ticker}>`{ticker}: ${price}`</div>`
))}
`</div>`
);
}
With batching, 1000 messages/second becomes 10 renders/second—well within 60 fps.
Approach 2: Throttling Renders
Throttle render updates to a target frame rate. requestAnimationFrame synchronizes with the browser's repaint:
import { useEffect, useRef, useState } from 'react';
export function useThrottledState(initialValue, flushInterval = 16) {
const [state, setState] = useState(initialValue);
const pendingRef = useRef(null);
const rafRef = useRef(null);
const setPending = (updater) => {
pendingRef.current = updater;
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
if (pendingRef.current) {
setState(pendingRef.current);
pendingRef.current = null;
}
rafRef.current = null;
});
}
};
useEffect(() => {
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, []);
return [state, setPending];
}
export function ThrottledChat() {
const [messages, setMessages] = useThrottledState([]);
useWebSocket('wss://your-server.example.com/chat', {
onMessage: (msg) => {
setMessages((prev) => [...prev, msg]);
},
});
return (
`<div>`
{messages.map((msg, idx) => (
`<div key={idx}>`{msg.text}`</div>`
))}
`</div>`
);
}
The hook batches state updates to run once per frame (every 16.7ms for 60 fps).
Approach 3: Virtual Scrolling
For large lists (1000+ items), render only visible rows:
import { FixedSizeList } from 'react-window';
export function VirtualizedMessages({ messages }) {
const Row = ({ index, style }) => (
`<div style={style}>`
{messages[index].text}
`</div>`
);
return (
`<FixedSizeList`
height={600}
itemCount={messages.length}
itemSize={60}
width="100%"
`>`
{Row}
`</FixedSizeList>`
);
}
With 10,000 messages, virtualization renders only ~10 visible rows, not all 10,000.
Approach 4: Unsubscribe from Unused Streams
Memory leaks accumulate from accumulated WebSocket listeners. Always clean up:
export function useWebSocketListener(url, handler) {
useEffect(() => {
const ws = new WebSocket(url);
ws.addEventListener('message', handler);
return () => {
ws.removeEventListener('message', handler); // Remove listener
ws.close(); // Close connection
};
}, [url, handler]);
}
If you have 10 tabs open, each listening to the same stream, you're consuming 10x memory and CPU.
Memory Management: Circular Buffers
For streams that run indefinitely (e.g., sensor data), a circular buffer prevents unbounded memory growth:
export class CircularBuffer {
constructor(maxSize = 1000) {
this.maxSize = maxSize;
this.buffer = [];
this.index = 0;
}
add(item) {
if (this.buffer.length < this.maxSize) {
this.buffer.push(item);
} else {
this.buffer[this.index] = item;
}
this.index = (this.index + 1) % this.maxSize;
}
getAll() {
return this.buffer.slice(this.index).concat(this.buffer.slice(0, this.index));
}
}
export function SensorStream() {
const [readings, setReadings] = useState(new CircularBuffer(500));
useWebSocket('wss://your-server.example.com/sensor', {
onMessage: (msg) => {
setReadings((prev) => {
const updated = new CircularBuffer(prev.maxSize);
updated.buffer = [...prev.buffer];
updated.index = prev.index;
updated.add(msg);
return updated;
});
},
});
return (
`<div>`
{readings.getAll().map((r, idx) => (
`<div key={idx}>`{r.value}`</div>`
))}
`</div>`
);
}
Now memory stays constant: only the last 500 readings are kept.
Comparison: Performance Strategies
| Strategy | Latency Impact | Complexity | Best For |
|---|---|---|---|
| Batching | +100ms | Medium | Stock tickers, feeds |
| Throttling (RAF) | +16ms | Medium | Chat, dashboards |
| Virtual scrolling | None | High | Long message lists |
| Circular buffer | None | Low | Infinite streams, memory-constrained |
Monitoring Performance
Use the Performance tab in DevTools:
- Open DevTools > Performance tab.
- Click Record.
- Send messages.
- Click Stop.
- Look for long tasks (> 50ms). They cause jank.
Red marks = frames dropped. Green = 60 fps. Target: sustained green after your optimization.
Also monitor memory:
// In browser console
performance.memory
// { usedJSHeapSize: 12345678, totalJSHeapSize: 87654321, jsHeapSizeLimit: 2266976666 }
If usedJSHeapSize grows unbounded, you have a memory leak.
Key Takeaways
- Batch updates for high-frequency streams; apply 50+ updates per render, not one per update.
- Throttle renders using
requestAnimationFrameto sync with the browser's repaint cycle. - Use virtual scrolling for large lists to render only visible items.
- Implement circular buffers for streams that run forever to prevent unbounded memory growth.
- Monitor with DevTools: watch for long tasks and memory leaks.
Frequently Asked Questions
How many updates per second is too many?
Depends on your app. 100 updates/second without batching causes jank. With batching, 10,000/second is feasible on modern hardware. Measure with DevTools.
Should I batch or throttle?
Batching is simpler for discrete messages (chat). Throttling is better for continuous streams (mouse movement, sensor data). Use both: batch messages into buckets, then throttle render.
What's the ideal batch size?
50–100 updates. Larger batches (200+) increase latency; smaller batches (10) cause more renders. Test and measure.
Does virtual scrolling work with WebSocket updates?
Yes. Use react-window with a growing array. When new items arrive, update the array and increment the item count. Virtual scrolling handles re-rendering visible rows.