Real-time Equipment Dashboard โ Streaming Incubator Status with WebSockets
After Completing This Topic
By combining the WebSockets, React state, and time-series buffers learned in the textbook, you will be able to create a dashboard that displays the temperature, CO2, and pH values emitted every second by a cell culture incubator in real-time on a browser. You, who only knew about HTTP requests and responses, will enter a world where you handle data pushed by the server.
This article is a general educational example. Cell culture monitoring is a common management task in laboratories, so it was chosen as the subject.
"I Found Out the Next Morning" โ The Limitations of Polling
Imagine you have a precious cell line in a cell culture incubator. The CO2 concentration in the incubator needs to be maintained at 5%. One night, someone accidentally leaves the door slightly ajar, causing the CO2 level to plummet. The next morning, you discover that all the cells have died.
To prevent such an incident, real-time monitoring is necessary: a system that "refreshes values every second and immediately alerts you if something is wrong."
Your first attempt might look like this:
// Naive polling approach
setInterval(async () => {
const res = await fetch("/api/incubator");
const data = await res.json();
updateChart(data);
}, 1000);It queries the server every second. This approach works, but it has significant drawbacks.
- Network Overhead: Every second, there's an HTTP request + response header exchange. Most of these requests are wasted because there's "no new data."
- Latency: Even if the incubator detected a sudden CO2 drop 0.1 seconds ago, your next polling interval (up to 1 second later) will still display the normal value on the screen.
- Scalability Issues: Monitoring 5 incubators means 5 requests per second. As you scale up, the server can be overwhelmed by the polling load.
WebSocket solves these problems. Instead of request/response round trips, it enables the server to push data to the client whenever new data is available. In this article, we'll build one from scratch.
Let's See the Finished Product First (Run the Black Box First)
The system we're building will look like this:
โโโโโโโโโโโโโโโโโโ WebSocket โโโโโโโโโโโโโโโโโโโโ
โ Python Server โ โโโโโโโโโโโโโโโโโโโโโโโโโโโ โ React Browser โ
โ (Mock Incubator) โ {temp, co2, pH, timestamp} โ (Dashboard) โ
โ โ โ Pushed multiple times per second โ โ
โโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโThe server pushes messages like this multiple times per second:
{"temp": 37.02, "co2": 4.98, "pH": 7.35, "timestamp": 1720000000.5}
{"temp": 37.03, "co2": 4.97, "pH": 7.35, "timestamp": 1720000000.7}
{"temp": 37.01, "co2": 4.98, "pH": 7.36, "timestamp": 1720000000.9}The browser receives these values and maintains a 60-second rolling window, displaying three line graphs in real-time. If the CO2 level goes outside the danger range (4.5 to 5.5), a warning banner appears at the top of the screen.
Without polling, without latency, the screen reacts as soon as the server generates the data.
What components does this tool consist of (component breakdown)?
Real-time Equipment Dashboard (Full Stack)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โ [Server Side] [Client Side] โ
โ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโ โ
โ โ Incubator Mock Data โ โ WebSocket Connection โ โ
โ โ (initial full) โ โ (receive โ React) โ โ
โ โโโโโโโโโโโฌโโโโโโโโ โโโโโโโโโโฌโโโโโโโโ โ
โ โ โ โ
โ โผ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโ โ
โ โ WebSocket Server โ โโโโโโโโโบ โ Time Series Buffer โ โ
โ โ Component: websocket โ โ (last 60 seconds) โ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโฌโโโโโโโโ โ
โ โ
Built from scratch โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโ โ
โ โ React Graph โ โ
โ โ + Alert Banner โ โ
โ โโโโโโโโโโโโโโโโโโ โ
โ โ
Built from scratch โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ| Component | Learned from | Function in this tool |
|---|---|---|
| HTML/CSS/React basics | html-css-react-basics | Page structure and component rendering |
| WebSocket | websocket-basics | Server โ client push communication |
| React state | react-state | Connects pushed data to the screen |
| Time series buffer | time-series-buffer | Maintains only the recent window, discards old values |
๐ If you are unfamiliar with these concepts (links at the top)
The new concepts that are built from scratch are WebSocket server, React state, and circular time series buffer. The basic HTML/CSS structure is provided as a complete tool. There are only three โ within the limits of cognitive capacity.
Step 1: Create a Mock Incubator Server โ (WebSocket Server)
โ๏ธ Fill-in-the-blank section. Component = WebSocket server (Python). The mock incubator will push state data multiple times per second.
We will use the websockets library in Python. This is a complete server that can be run locally.
# server.pyimport asyncioimport jsonimport timeimport random
import websockets # pip install websockets
class MockIncubator: """Mock incubator โ simulates temperature/CO2/pH with some noise.""" def __init__(self, target_temp=37.0, target_co2=5.0, target_ph=7.35): self.target_temp = target_temp self.target_co2 = target_co2 self.target_ph = target_ph # Simulate a slightly open door self.door_open = False
def sample(self) -> dict: # Normal state: target value ยฑ small noise temp_noise = 0.15 co2_noise = 0.10 if not self.door_open else 0.8 ph_noise = 0.02 return { "temp": round(self.target_temp + random.gauss(0, temp_noise), 3), "co2": round(self.target_co2 + random.gauss(0, co2_noise), 3), "pH": round(self.target_ph + random.gauss(0, ph_noise), 3), "timestamp": round(time.time(), 3), "door_open": self.door_open, }
incubator = MockIncubator()
async def stream_handler(websocket): """This function is called for each new client connection.""" print(f"[+] Client connected: {websocket.remote_address}") try: while True: data = incubator.sample() await websocket.send(json.dumps(data)) await asyncio.sleep(0.5) # Push data twice per second except websockets.ConnectionClosed: print("[-] Client disconnected")
async def main(): async with websockets.serve(stream_handler, "localhost", 8765): print("Server started: ws://localhost:8765") await asyncio.Future() # forever
if __name__ == "__main__": asyncio.run(main())In this code, the critical point is the send and sleep combination inside the while True loop. In each iteration, a new sample is created and pushed to the client. It doesn't wait for requests like HTTP.
Verify with a local test:
# test_server.py โ Verify that the server is pushing data by mimicking a clientimport asyncioimport jsonimport websockets
async def sanity_check(): async with websockets.connect("ws://localhost:8765") as ws: # Receive 5 messages messages = [] for _ in range(5): raw = await ws.recv() messages.append(json.loads(raw)) # Verification assert len(messages) == 5 for m in messages: assert set(m.keys()) >= {"temp", "co2", "pH", "timestamp"} assert 35 < m["temp"] < 39 # Normal range assert 3 < m["co2"] < 7 assert 6.8 < m["pH"] < 8.0 # Timestamps should increase chronologically times = [m["timestamp"] for m in messages] assert all(times[i] <= times[i+1] for i in range(len(times)-1))
# asyncio.run(sanity_check()) # Run when the server is running๐ How is WebSocket different from polling (drawing โ websocket-basics)? HTTP repeats request โ response โ connection close each time. WebSocket is a bidirectional channel that remains connected indefinitely. The server can push data whenever it wants, and the client can send data whenever it wants. It is the standard for chat, games, and real-time monitoring.
๐ค Self-explanatory prompt I intentionally put
await asyncio.sleep(0.5)inside thewhile Trueloop. What will happen if you remove it? What will be the CPU usage of the server? How many messages per second will the client receive? (Hint: without sleep, CPU will be 100% + thousands of messages per millisecond)
Step 2: Build a Time-Series Buffer โ
โ๏ธ A self-contained section. Component = Circular Time-Series Buffer. It keeps only the most recent N points and automatically discards older values.
Real-time dashboards do not need to store all historical data. It's sufficient to look at the latest 60-second window. If data arrives at 2 points per second, a 60-second window equals a maximum of 120 points.
The simplest approach: continuously push data into a JavaScript array and occasionally trim the beginning using shift.
// Naive buffer - has issues
let buffer = [];
function push(point) {
buffer.push(point);
if (buffer.length > 120) buffer.shift();
}This approach works, but it has a problem. Array.shift() is O(n) โ removing an element from the beginning of the array causes all subsequent elements to shift one position. As the data ages, the O(n) shift cost accumulates with each push.
We achieve O(1) push/pop with a circular buffer.
๐ What is a circular buffer (drawer โ time-series-buffer)? It uses a fixed-size array like a circle. A single write index tracks where to write the next value. When the end of the array is reached, it wraps around to the beginning, overwriting older values. This structure makes both push and pop O(1). It's frequently used in audio processing, log rings, and real-time graphs.
// buffer.ts
export class TimeSeriesBuffer<T> {
private data: (T | null)[];
private writeIdx = 0;
private size = 0;
constructor(public capacity: number) {
this.data = new Array(capacity).fill(null);
}
push(value: T): void {
this.data[this.writeIdx] = value;
this.writeIdx = (this.writeIdx + 1) % this.capacity;
this.size = Math.min(this.size + 1, this.capacity);
}
toArray(): T[] {
// Return an array sorted in time order
if (this.size < this.capacity) {
// Not yet full - return elements from the beginning up to size
return this.data.slice(0, this.size) as T[];
}
// Full - wrap around and return from writeIdx to the end
return [
...this.data.slice(this.writeIdx),
...this.data.slice(0, this.writeIdx),
] as T[];
}
}Verification:
// buffer.test.ts
import { TimeSeriesBuffer } from "./buffer";
const buf = new TimeSeriesBuffer<number>(5);
[1, 2, 3].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[1,2,3]");
[4, 5].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[1,2,3,4,5]");
// Exceed capacity - overwrite older values
[6, 7].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[3,4,5,6,7]");We replaced the O(n) shift() with an O(1) writeIdx increment. The push cost remains constant, even as the data ages.
๐ค Self-explanatory prompt: Why is
toArray()O(n)? Push is O(1), so why is the return O(n)? Will this be a problem in a real-time graph rendering scenario? (Hint: Rendering at 60Hz means O(120) per frame is negligible.)
Step 3: Building the React State Hook โ (react-state)
โ๏ธ Fill-in section. Component = React state + useEffect. Store values pushed from the WebSocket in a buffer and reflect them on the screen.
When dealing with real-time data in React, the key principle is the combination of useState + useEffect.
- useState: A snapshot of the "current window" to be rendered on the screen.
- useEffect: Manages the lifecycle of WebSocket connections and disconnections.
// useIncubatorStream.ts
import { useEffect, useState, useRef } from "react";
import { TimeSeriesBuffer } from "./buffer";
interface Sample {
temp: number;
co2: number;
pH: number;
timestamp: number;
door_open?: boolean;
}
export function useIncubatorStream(wsUrl: string, windowSize: number = 120) {
const [samples, setSamples] = useState<Sample[]>([]);
const [connected, setConnected] = useState(false);
const bufferRef = useRef(new TimeSeriesBuffer<Sample>(windowSize));
useEffect(() => {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log("WebSocket connected");
setConnected(true);
};
ws.onmessage = (event) => {
const data: Sample = JSON.parse(event.data);
bufferRef.current.push(data);
// Pass the new snapshot to the React state
setSamples(bufferRef.current.toArray());
};
ws.onclose = () => {
console.log("WebSocket closed");
setConnected(false);
};
// Cleanup: Close the connection when the component unmounts
return () => {
ws.close();
};
}, [wsUrl, windowSize]);
return { samples, connected };
}The key features of this hook are:
bufferRef(useRef): The circular buffer should be maintained independently of component re-renders. Wrap it withuseRef.setSamples: Returns a new array with each message, causing React to re-render. The array reference must change for React to detect the change.- Cleanup function: Always call
ws.close()when the component disappears. Otherwise, zombie connections will accumulate.
๐ When to use useRef vs. useState (drawer โ react-state) Use useState if it affects the screen (re-renders on change). If only internal calculations or reference maintenance are needed, use useRef (does not re-render on change). Use useRef for the circular buffer itself, and useState for the snapshot, which is the target of the graph rendering โ this separation is key to performance.
๐ค Self-explanatory prompt What would happen if you simply placed
bufferRefinside the component aslet buffer = new TimeSeriesBuffer(...)? (Hint: A new buffer would be created with each render, and all past data would be lost.)
Step 4: Building the Dashboard UI โ
Now for the final layer: rendering the samples from the hook as actual graphs and warning banners.
// IncubatorDashboard.tsx
import { useIncubatorStream } from "./useIncubatorStream";
function isCritical(latest: any): string | null {
if (!latest) return null;
if (latest.co2 < 4.5 || latest.co2 > 5.5) return `CO2 Alert: ${latest.co2}%`;
if (latest.temp < 36.5 || latest.temp > 37.5) return `Temperature Alert: ${latest.temp}ยฐC`;
if (latest.pH < 7.2 || latest.pH > 7.5) return `pH Alert: ${latest.pH}`;
return null;
}
export function IncubatorDashboard() {
const { samples, connected } = useIncubatorStream("ws://localhost:8765");
const latest = samples[samples.length - 1];
const warning = isCritical(latest);
return (
<div className="dashboard">
<header>
<h1>Incubator A</h1>
<span className={connected ? "connected" : "disconnected"}>
{connected ? "Connected Live" : "Disconnected"}
</span>
</header>
{warning && (
<div className="warning-banner">
โ ๏ธ Alert: {warning}
</div>
)}
<section className="metrics">
<MetricCard label="Temperature" value={latest?.temp} unit="ยฐC" target={37.0} />
<MetricCard label="CO2" value={latest?.co2} unit="%" target={5.0} />
<MetricCard label="pH" value={latest?.pH} unit="" target={7.35} />
</section>
<section className="charts">
<LineChart data={samples} field="temp" color="#ff6b6b" />
<LineChart data={samples} field="co2" color="#4ecdc4" />
<LineChart data={samples} field="pH" color="#95e1d3" />
</section>
</div>
);
}MetricCard and LineChart are standard components, so the detailed implementation is omitted. The key point is that this single hook serves as the data source for the entire dashboard.
Validation (Integration Test Overview):
// integration.test.ts (Playwright/Cypress integration test concept)
async function test_dashboard_flow() {
// 1. Start the server (test fixture)
// 2. Load the dashboard in the browser
// 3. Within 5 seconds, at least 5 data points should appear in the graph
await waitFor(() => expect(chart.pointCount()).toBeGreaterThan(5));
// 4. The WebSocket connection should be alive
expect(document.querySelector(".connected")).toBeInTheDocument();
// 5. If we artificially change the state to door_open=true, the warning banner should appear
await triggerDoorOpen(server);
await waitFor(() => expect(document.querySelector(".warning-banner")).toBeInTheDocument());
}Combining the Pieces โ A Complete Dashboard Architecture
Here's how the entire system fits together:
Server-Side (Python):
MockIncubator โ Repeatedly calls sample() โ WebSocket.send()
File: server.py
Client-Side (React/TypeScript):
useIncubatorStream(wsUrl)
โโโ WebSocket connection (useEffect)
โโโ TimeSeriesBuffer (useRef, O(1) push)
โโโ samples state (useState, triggers re-render)
IncubatorDashboard
โโโ Displays connection status
โโโ Warning banner (isCritical function)
โโโ 3 MetricCards (current value)
โโโ 3 LineCharts (60-second window)
Files: useIncubatorStream.ts, buffer.ts, IncubatorDashboard.tsxThis tool is a miniature version of the real-time dashboards you might see today in industrial SCADA (Supervisory Control and Data Acquisition) systems, laboratory LIMS (Laboratory Information Management Systems), and more. A production-ready tool would simply add features like authentication, multi-device routing, time-series DB storage (InfluxDB), and notification routing (Slack/email).
Performance Deep Dive โ Why This Architecture?
Polling method (fetch every 1 second):
- Server load: N clients ร 1 request per second = N req/sec
- Latency: Average 0.5 seconds (half of the polling interval)
- Bandwidth: HTTP header overhead for each request (~500 bytes)
WebSocket method (continuous push):
- Server load: Constant maintenance for each connection (1 socket, TCP maintenance)
- Latency: ~Milliseconds (only network round trip)
- Bandwidth: Only payload, no header (~80 bytes)Compared numerically:
| Scenario | Polling (1-second interval) | WebSocket |
|---|---|---|
| 100 clients, requests per second | 100 req/sec | 0 req/sec |
| Anomaly detection latency (average) | 500ms | ~10ms |
| Payload bandwidth (per second) | 100 ร 500B = 50KB/s | 100 ร 80B ร 2 = 16KB/s |
Anomaly detection latency is 50 times faster, and bandwidth is 1/3. The gap widens as more devices are scaled.
There Are Other Paths (Multipath Reflection)
- Server-Sent Events (SSE): A simpler alternative to WebSockets. It supports only unidirectional communication from server to client, but it can easily pass through proxies and firewalls by being built on top of HTTP. If the client doesn't need to send commands to the server (e.g., simple monitoring), SSE is sufficient.
- MQTT Broker: An IoT standard protocol. Connect multiple devices to a broker and have the dashboard subscribe to the broker as well. This is a better solution if you have dozens or hundreds of devices. Open-source options include Mosquitto and HiveMQ.
- Use a Time Series Database: Real-time dashboards alone don't provide historical logs. By recording data simultaneously in InfluxDB, TimescaleDB, or Prometheus, you can enable both real-time views and historical analysis.
- Reconnect Logic: Our hook does not reconnect if the server connection is lost. In practice, it is essential to have reconnection logic with exponential backoff. Place the retry schedule inside
ws.onclose. - Expanding Alert Delivery: Displaying alerts only on the screen banner makes it impossible to respond to incidents overnight. To be truly useful, you need to push alerts to external channels such as Slack webhooks, SMS, or PagerDuty.
Key takeaway: "The server pushes data, the buffer maintains the window, and the state connects the screen." If each of these three layers knows its role, you can see the architecture of a real-time system. What you just created is the embodiment of that architecture.
Next Steps (Links at the bottom)
- Detailed explanation of the WebSocket protocol โ WebSocket Basics
- Various variations of circular buffers โ Time Series Buffer
- Combine with the visualization created earlier โ RNA-seq Heatmap
Try It Yourself (Independent Exercises)
- Reconnect Logic: Add logic to the hook that, when a WebSocket connection is dropped, retries the connection after 1 second, 2 seconds, 4 seconds, and 8 seconds (exponential backoff).
- Multiple Devices: Expand the dashboard to monitor 5 incubators. Each device should have its own separate WebSocket connection. Observe where the performance bottleneck arises.
- Downsampling: If 20 messages per second are received, you want to limit the screen rendering to 5 times per second. Add debounce/throttle to the hook.
- Challenge: SSE Migration: Reimplement the same system using Server-Sent Events instead of WebSockets. How does the code complexity change?
Summary
We tackled the problem of "instantly reflecting real-time data emitted by equipment on the screen" by breaking it down into three components.
- WebSocket opened a server-push communication channel (no polling).
- A circular time-series buffer maintained the most recent window with O(1) push operations.
- React state (+ useRef) served as the bridge to accurately connect the data flow and rendering.
The values emitted by the incubator every second now flow to your screen with millisecond latency. The days of discovering an incident the next morning are over. An alert appears on the screen within 5 seconds of the incident.
This article is a general educational example. A real-world laboratory dashboard would add authentication, multi-equipment routing, time-series databases, and notification systems to this. A more detailed version can be built on top of this framework, or you can rely on proven SCADA/LIMS tools.