Why Your React Dashboard Lags at 50Hz (And What to Do Instead)

Learn why high-frequency telemetry (like drone attitude at 50Hz) crashes React’s render cycle—and how to keep your dashboard smooth by moving state updates *outside* the React reconciliation path.
The “Smooth” Dashboard That Isn’t
You’ve built a sleek React dashboard for monitoring a drone—or a robot, sensor array, or industrial PLC. You wire up WebSocket messages, call setState for each new reading, and watch as your gauges, maps, and flight displays start… stuttering.
At first glance, it looks fine in dev mode. Then you plug in real data: attitude updates at 50Hz, GPS at 10Hz, battery voltage every 200ms. Suddenly, your PFD lags behind the map. The altitude tape jumps while the vertical speed needle freezes. Your event log scrolls after the aircraft has already drifted off course.
This isn’t a bug in your code. It’s React doing exactly what it’s designed to do—reconcile changes on every state update—and hitting a hard wall: the browser’s 60fps render budget.
Let’s walk through why this happens—and how to fix it without rewriting your app in vanilla JS.
Why 50Hz Breaks React (Spoiler: It’s Not the FPS)
React doesn’t render on demand. It batches state updates and schedules renders during the browser’s next animation frame (~16ms). If you call setState 50 times per second (i.e., every ~20ms), React tries to reconcile all those updates within one frame. Worse: each update triggers re-renders across every component subscribed to that state—even if only one number changed.
So with five telemetry channels (roll, pitch, yaw, altitude, airspeed), updating at 50Hz, you’re forcing React to process 250 state changes per second, most of which are irrelevant to any given component. The result? Dropped frames, inconsistent timestamps across instruments, and UI that feels “rubbery” or unresponsive.
💡 Key insight: React is optimized for user-initiated interactions (clicks, form input), not machine-generated firehose data. Trying to force telemetry through
useStateis like routing a firehose through a soda straw.
Step 1: Build the Layout First (No Drone Required)
Before touching real data, build your dashboard against mock motion. This isolates layout, styling, and timing issues from data plumbing.
Install the essentials:
npm install @altara/core @altara/aerospace
npm install leaflet@^1.9.4 react-leaflet@^4.2.1Now create a FlightDeck.tsx with two animated panels—no state, no effects, no providers:
import { PrimaryFlightDisplay } from '@altara/aerospace';
import { LiveMap } from '@altara/core';
export function FlightDeck() {
return (
<div style={{ display: 'flex', gap: 16 }}>
<PrimaryFlightDisplay mockMode size="md" showFlightDirector />
<LiveMap mockMode />
</div>
);
}That’s it. The mockMode prop activates built-in motion generators—not random noise, but physically plausible behavior:
- The PFD simulates gentle coordinated turns (bank + pitch out of phase).
- The map traces a slow circle, rotating to match heading.
- All motion runs on
requestAnimationFrame, outside React’s render loop.
Why does this matter? Because it proves your UI can animate smoothly before you add data. If it stutters here, the issue is CSS layout or paint performance—not telemetry.
Step 2: Assemble the Full Dashboard (Still Fake)
Now expand to four panels in a grid. Notice how mockMode works differently per component:
import { LiveMap, Gauge, EventLog, type EventLogEntry } from '@altara/core';
import { PrimaryFlightDisplay } from '@altara/aerospace';
const demoEvents: EventLogEntry[] = [
{ timestamp: Date.now() - 9000, severity: 'info', message: 'EKF using GPS' },
{ timestamp: Date.now() - 6000, severity: 'info', message: 'Armed' },
{ timestamp: Date.now() - 3000, severity: 'warn', message: 'Wind 11 m/s, approaching limit' },
{ timestamp: Date.now() - 1000, severity: 'info', message: 'Waypoint 3 reached' },
];
export function GroundControlStation() {
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 16,
padding: 16,
alignItems: 'start',
}}
>
<PrimaryFlightDisplay mockMode size="md" showFlightDirector />
<LiveMap mockMode />
<Gauge
mockMode
mockProfile="ramp"
min={0}
max={100}
label="Battery"
unit="%"
thresholds={[
{ value: 0, color: 'var(--vt-color-danger)' },
{ value: 20, color: 'var(--vt-color-warn)' },
{ value: 40, color: 'var(--vt-color-active)' },
]}
/>
<EventLog entries={demoEvents} />
</div>
);
}Two details stand out:
GaugeusesmockProfile="ramp"instead of the default sine wave—because batteries drain, they don’t oscillate. Mock data should reflect physics, not convenience.EventLogtakes staticentries, notmockMode. Why? Text logs aren’t scalar values—they’re discrete events. Forcing them through a mock generator would obscure their real shape (timestamp,severity,message). So you own that array explicitly.
This approach lets you finalize spacing, colors, thresholds, and responsive behavior before connecting hardware. The hardest part of a dashboard isn’t parsing MAVLink—it’s making sure the PFD fits beside the map without overflow.
Step 3: Replace Mocks With Real Data (Without Rewriting)
Now swap mockMode for live data—but keep exactly the same component tree. The secret? Decouple data ingestion from rendering.
Altara components accept a dataSource prop—a simple object with a subscribe() method that emits { timestamp, value } objects. No React state. No useEffect spaghetti.
Here’s how to wire MAVROS over rosbridge:
import { useEffect, useMemo, useState } from 'react';
import { EventLog, Gauge, LiveMap } from '@altara/core';
import { PrimaryFlightDisplay } from '@altara/aerospace';
import {
createBatteryStateAdapter,
createImuAdapter,
createRosbridgeAdapter,
mergeChannels,
} from '@altara/ros';
export function LiveGroundControlStation() {
const [batterySource, setBatterySource] = useState<any>(null);
const [imuSource, setImuSource] = useState<any>(null);
const [logEntries, setLogEntries] = useState<EventLogEntry[]>([]);
// Connect to rosbridge once
useEffect(() => {
const adapter = createRosbridgeAdapter('ws://localhost:9090');
// Convert ROS /battery topic → battery % (0–100)
const battery$ = adapter.topic('/battery', createBatteryStateAdapter());
setBatterySource(battery$);
// Convert ROS /mavros/imu/data → roll/pitch/yaw/altitude/airspeed
const imu$ = adapter.topic('/mavros/imu/data', createImuAdapter());
setImuSource(imu$);
// Subscribe to log stream
adapter.topic('/mavros/statustext/recv', (msg) => {
setLogEntries(prev => [
...prev.slice(-9), // Keep last 10
{
timestamp: Date.now(),
severity: msg.severity === 2 ? 'warn' : 'info',
message: msg.text,
}
]);
});
}, []);
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, padding: 16 }}>
<PrimaryFlightDisplay dataSource={imuSource} size="md" showFlightDirector />
<LiveMap dataSource={imuSource} />
<Gauge dataSource={batterySource} label="Battery" unit="%" /* ... */ />
<EventLog entries={logEntries} />
</div>
);
}Notice what’s not happening:
- No
useStatefor roll, pitch, yaw, etc. - No
useEffectper channel. - No manual
requestAnimationFrameorsetTimeoutthrottling.
The dataSource abstraction moves telemetry handling outside React’s render cycle. Updates flow directly into canvas/WebGL layers (PFD), Leaflet (map), or SVG (gauge)—bypassing reconciliation entirely.
The Shift: From “React State” to “Data Pipeline”
You’re no longer asking React to render 50Hz data. You’re asking it to orchestrate a pipeline:
createRosbridgeAdapterconnects to WebSocket.- Adapters transform ROS messages into normalized telemetry streams.
- Components subscribe and draw—using
requestAnimationFrame,canvas, orLeaflet’s native rendering.
This is the architectural shift: React manages what to show, not how often to update. The heavy lifting happens in dedicated, optimized rendering paths—not in component render() functions.
Start with mock mode. Lock down layout. Then swap in dataSource. Your dashboard stays snappy—not because you optimized useMemo, but because you stopped fighting React’s grain.
And when your drone finally takes off? Your dashboard won’t lag. It’ll just… keep up.