If you have ever built a web interface for your ESP32, you have probably used the classic approach: the browser sends an HTTP request, the ESP32 responds with sensor data, and a JavaScript setInterval polls every few seconds for updates. It works, but it is clunky, wasteful, and never feels truly real-time.
There is a better way. WebSockets give you a persistent, bidirectional connection between the browser and your ESP32. Sensor readings arrive the instant they change. Button presses on the dashboard reach the microcontroller in milliseconds. No polling. No wasted requests. Just a clean, open pipe.
In this tutorial, you will build a complete real-time sensor dashboard with live-updating gauges and bidirectional device control — all running on a single ESP32.
The Problem with HTTP Polling
The traditional approach looks something like this:
// Browser-side polling — the old way
setInterval(async () => {
const res = await fetch("/api/sensors");
const data = await res.json();
updateDashboard(data);
}, 2000);
Every 2 seconds, the browser opens a new TCP connection, sends HTTP headers, waits for a response, and closes the connection. Multiply that by 5 connected clients and your ESP32 is handling 150 HTTP requests per minute — most returning identical data because nothing changed.
Problems with polling:
- Wasted bandwidth — full HTTP headers on every request, even when data has not changed
- Artificial latency — if your poll interval is 2 seconds, you could miss an event by up to 2 seconds
- Server load — each request is a full HTTP transaction with TCP overhead
- Not scalable — more clients means linearly more requests
- No server push — the ESP32 cannot notify the browser; the browser must ask
What Are WebSockets?
A WebSocket is a persistent, full-duplex communication channel over a single TCP connection. After an initial HTTP handshake (the "upgrade" request), the connection stays open. Both sides can send messages at any time without the overhead of new HTTP requests.
Key characteristics:
- Persistent connection — opened once, stays alive for the session
- Bidirectional — both client and server can send messages at any time
- Low latency — no HTTP overhead per message, just raw frames
- Event-driven — messages arrive as events, no polling needed
- Lightweight frames — a WebSocket frame header is just 2-10 bytes vs hundreds of bytes for HTTP headers
WebSocket vs HTTP Polling vs Server-Sent Events
| Feature | HTTP Polling | Server-Sent Events (SSE) | WebSocket |
|---|---|---|---|
| Direction | Client to server only | Server to client only | Bidirectional |
| Connection | New connection each request | Persistent (one-way) | Persistent (two-way) |
| Latency | High (poll interval) | Low | Very low |
| Overhead per message | Full HTTP headers (~200-800 bytes) | Small | Minimal (2-10 byte frame) |
| Browser support | Universal | Good (no IE) | Universal |
| ESP32 memory | Low (stateless) | Medium | Medium-High |
| Best for | Infrequent, non-critical data | Live feeds, logs, dashboards | Interactive control + live data |
| Max clients on ESP32 | Many (stateless) | ~8-10 | ~4-8 |
Use WebSockets when you need both live data streaming AND user interaction (toggling relays, setting thresholds, controlling LEDs). For read-only dashboards, SSE can work, but WebSockets give you the full picture.
Library Setup
We will use the ESPAsyncWebServer library, which includes built-in WebSocket support. It runs on the ESP32's async TCP stack, handling multiple clients without blocking your main loop.
Install via PlatformIO (platformio.ini):
[env:esp32]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
ESP Async WebServer
Arduino_JSON
monitor_speed = 115200
Or via Arduino IDE, install these libraries through the Library Manager:
ESPAsyncWebServerby Me-No-DevAsyncTCPby Me-No-DevArduino_JSONby Arduino
Code: Basic WebSocket Echo Server
Let us start with the simplest possible WebSocket server — it echoes back whatever the client sends.
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");
void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client,
AwsEventType type, void *arg, uint8_t *data, size_t len) {
switch (type) {
case WS_EVT_CONNECT:
Serial.printf("Client #%u connected from %s\n",
client->id(), client->remoteIP().toString().c_str());
client->text("Welcome! You are client #" + String(client->id()));
break;
case WS_EVT_DISCONNECT:
Serial.printf("Client #%u disconnected\n", client->id());
break;
case WS_EVT_DATA: {
AwsFrameInfo *info = (AwsFrameInfo*)arg;
if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
data[len] = 0; // null-terminate
Serial.printf("Client #%u sent: %s\n", client->id(), (char*)data);
// Echo it back
client->text("Echo: " + String((char*)data));
}
break;
}
case WS_EVT_ERROR:
Serial.printf("Client #%u error\n", client->id());
break;
case WS_EVT_PONG:
break;
}
}
void setup() {
Serial.begin(115200);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.printf("\nConnected! IP: %s\n", WiFi.localIP().toString().c_str());
ws.onEvent(onWsEvent);
server.addHandler(&ws);
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send(200, "text/plain", "WebSocket Echo Server");
});
server.begin();
}
void loop() {
ws.cleanupClients(); // close stale connections
}
Flash this, open the Serial Monitor to see the IP address, then test from your browser console:
const ws = new WebSocket("ws://192.168.1.100/ws");
ws.onmessage = (e) => console.log(e.data);
ws.onopen = () => ws.send("Hello ESP32!");
Designing a JSON Message Protocol
Before building the dashboard, let us define a clean message format. Structured JSON messages make your system extensible and easy to debug.
ESP32 to Browser (sensor data):
{
"type": "sensor_data",
"temperature": 27.5,
"humidity": 63.2,
"pressure": 1013.25,
"timestamp": 145230
}
Browser to ESP32 (commands):
{
"type": "command",
"action": "toggle_relay",
"pin": 26,
"state": true
}
ESP32 to Browser (state confirmation):
{
"type": "state_update",
"relay_26": true,
"led_color": "#00FF00"
}
Using a "type" field lets both sides route messages to the correct handler. This pattern scales well as you add more sensors and controls.
Code: Real-Time Sensor Dashboard
Now the full project. The ESP32 reads sensor data (simulated here for easy testing, but you can drop in a real BME280), sends it over WebSocket, and serves a complete HTML dashboard.
ESP32 Firmware
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <Arduino_JSON.h>
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");
// Relay and LED state
bool relayState = false;
const int RELAY_PIN = 26;
const int LED_PIN = 2;
int ledBrightness = 0;
// Client tracking
unsigned long lastSensorSend = 0;
const unsigned long SENSOR_INTERVAL = 1000; // 1 second
// Forward declaration
void notifyClients(const String &message);
String getSensorJSON();
// --- Simulated sensor readings (replace with real sensor code) ---
float readTemperature() {
return 25.0 + (float)(random(-30, 30)) / 10.0;
}
float readHumidity() {
return 60.0 + (float)(random(-50, 50)) / 10.0;
}
float readPressure() {
return 1013.0 + (float)(random(-20, 20)) / 10.0;
}
String getSensorJSON() {
JSONVar doc;
doc["type"] = "sensor_data";
doc["temperature"] = round(readTemperature() * 10.0) / 10.0;
doc["humidity"] = round(readHumidity() * 10.0) / 10.0;
doc["pressure"] = round(readPressure() * 10.0) / 10.0;
doc["uptime"] = millis() / 1000;
doc["clients"] = ws.count();
doc["free_heap"] = ESP.getFreeHeap();
return JSON.stringify(doc);
}
String getStateJSON() {
JSONVar doc;
doc["type"] = "state_update";
doc["relay"] = relayState;
doc["led_brightness"] = ledBrightness;
return JSON.stringify(doc);
}
void notifyClients(const String &message) {
ws.textAll(message);
}
void handleCommand(AsyncWebSocketClient *client, const String &message) {
JSONVar doc = JSON.parse(message);
if (JSON.typeof(doc) == "undefined") {
client->text("{\"type\":\"error\",\"message\":\"Invalid JSON\"}");
return;
}
String type = (const char*)doc["type"];
if (type == "command") {
String action = (const char*)doc["action"];
if (action == "toggle_relay") {
relayState = !relayState;
digitalWrite(RELAY_PIN, relayState ? HIGH : LOW);
Serial.printf("Relay toggled: %s\n", relayState ? "ON" : "OFF");
// Broadcast new state to ALL clients
notifyClients(getStateJSON());
}
else if (action == "set_led") {
ledBrightness = (int)doc["brightness"];
ledBrightness = constrain(ledBrightness, 0, 255);
analogWrite(LED_PIN, ledBrightness);
Serial.printf("LED brightness set to: %d\n", ledBrightness);
notifyClients(getStateJSON());
}
else if (action == "get_state") {
// Send current state to requesting client only
client->text(getStateJSON());
}
}
}
void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client,
AwsEventType type, void *arg, uint8_t *data, size_t len) {
switch (type) {
case WS_EVT_CONNECT:
Serial.printf("[WS] Client #%u connected (%s) | Total: %u\n",
client->id(),
client->remoteIP().toString().c_str(),
ws.count());
// Send current state to new client
client->text(getStateJSON());
client->text(getSensorJSON());
break;
case WS_EVT_DISCONNECT:
Serial.printf("[WS] Client #%u disconnected | Total: %u\n",
client->id(), ws.count());
break;
case WS_EVT_DATA: {
AwsFrameInfo *info = (AwsFrameInfo*)arg;
if (info->final && info->index == 0 && info->len == len
&& info->opcode == WS_TEXT) {
data[len] = 0;
handleCommand(client, String((char*)data));
}
break;
}
case WS_EVT_ERROR:
Serial.printf("[WS] Client #%u error\n", client->id());
break;
case WS_EVT_PONG:
break;
}
}
// --- HTML served from program memory ---
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 Real-Time Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a; color: #e2e8f0;
min-height: 100vh;
}
.header {
background: #1e293b; padding: 16px 24px;
display: flex; justify-content: space-between; align-items: center;
border-bottom: 1px solid #334155;
}
.header h1 { font-size: 1.25rem; font-weight: 600; }
.status {
display: flex; align-items: center; gap: 8px;
font-size: 0.875rem;
}
.status-dot {
width: 10px; height: 10px; border-radius: 50%;
background: #ef4444; /* red = disconnected */
}
.status-dot.connected { background: #22c55e; }
.container { max-width: 900px; margin: 0 auto; padding: 24px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.card {
background: #1e293b; border-radius: 12px; padding: 20px;
border: 1px solid #334155; text-align: center;
}
.card-label {
font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.05em; color: #94a3b8; margin-bottom: 8px;
}
.card-value {
font-size: 2.25rem; font-weight: 700;
font-variant-numeric: tabular-nums;
}
.card-unit { font-size: 1rem; font-weight: 400; color: #94a3b8; }
.temp .card-value { color: #f97316; }
.humid .card-value { color: #3b82f6; }
.press .card-value { color: #a78bfa; }
.gauge-bar {
height: 6px; border-radius: 3px; background: #334155;
margin-top: 12px; overflow: hidden;
}
.gauge-fill {
height: 100%; border-radius: 3px;
transition: width 0.4s ease;
}
.temp .gauge-fill { background: #f97316; }
.humid .gauge-fill { background: #3b82f6; }
.press .gauge-fill { background: #a78bfa; }
.controls {
margin-top: 24px; display: grid;
grid-template-columns: 1fr 1fr; gap: 16px;
}
.control-card {
background: #1e293b; border-radius: 12px; padding: 20px;
border: 1px solid #334155;
}
.control-card h3 {
font-size: 0.875rem; font-weight: 600; margin-bottom: 12px;
}
.toggle-btn {
width: 100%; padding: 12px; border: none; border-radius: 8px;
font-size: 0.875rem; font-weight: 600; cursor: pointer;
transition: all 0.2s;
}
.toggle-btn.off {
background: #334155; color: #94a3b8;
}
.toggle-btn.on {
background: #22c55e; color: #fff;
}
.slider-container { display: flex; align-items: center; gap: 12px; }
.slider {
flex: 1; -webkit-appearance: none; height: 6px;
border-radius: 3px; background: #334155; outline: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none; width: 20px; height: 20px;
border-radius: 50%; background: #3b82f6; cursor: pointer;
}
.slider-value {
font-size: 1rem; font-weight: 600; min-width: 40px;
text-align: right; font-variant-numeric: tabular-nums;
}
.meta {
margin-top: 24px; display: flex; justify-content: space-between;
font-size: 0.75rem; color: #64748b;
}
@media (max-width: 640px) {
.grid { grid-template-columns: 1fr; }
.controls { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="header">
<h1>ESP32 Dashboard</h1>
<div class="status">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Disconnected</span>
</div>
</div>
<div class="container">
<div class="grid">
<div class="card temp">
<div class="card-label">Temperature</div>
<div class="card-value"><span id="temp">--</span><span class="card-unit"> C</span></div>
<div class="gauge-bar"><div class="gauge-fill" id="tempBar" style="width:0%"></div></div>
</div>
<div class="card humid">
<div class="card-label">Humidity</div>
<div class="card-value"><span id="humid">--</span><span class="card-unit"> %</span></div>
<div class="gauge-bar"><div class="gauge-fill" id="humidBar" style="width:0%"></div></div>
</div>
<div class="card press">
<div class="card-label">Pressure</div>
<div class="card-value"><span id="press">--</span><span class="card-unit"> hPa</span></div>
<div class="gauge-bar"><div class="gauge-fill" id="pressBar" style="width:0%"></div></div>
</div>
</div>
<div class="controls">
<div class="control-card">
<h3>Relay Control</h3>
<button class="toggle-btn off" id="relayBtn" onclick="toggleRelay()">
OFF
</button>
</div>
<div class="control-card">
<h3>LED Brightness</h3>
<div class="slider-container">
<input type="range" class="slider" id="ledSlider"
min="0" max="255" value="0"
oninput="setLed(this.value)">
<span class="slider-value" id="ledValue">0</span>
</div>
</div>
</div>
<div class="meta">
<span>Uptime: <span id="uptime">0s</span></span>
<span>Clients: <span id="clients">0</span></span>
<span>Free heap: <span id="heap">0</span> bytes</span>
</div>
</div>
<script>
let ws;
let reconnectInterval = null;
function connect() {
const host = window.location.hostname;
ws = new WebSocket(`ws://${host}/ws`);
ws.onopen = () => {
console.log("WebSocket connected");
document.getElementById("statusDot").classList.add("connected");
document.getElementById("statusText").textContent = "Connected";
if (reconnectInterval) {
clearInterval(reconnectInterval);
reconnectInterval = null;
}
// Request current state
ws.send(JSON.stringify({ type: "command", action: "get_state" }));
};
ws.onclose = () => {
console.log("WebSocket disconnected");
document.getElementById("statusDot").classList.remove("connected");
document.getElementById("statusText").textContent = "Reconnecting...";
if (!reconnectInterval) {
reconnectInterval = setInterval(() => {
console.log("Attempting reconnect...");
connect();
}, 3000);
}
};
ws.onerror = (err) => {
console.error("WebSocket error:", err);
ws.close();
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "sensor_data") {
document.getElementById("temp").textContent = data.temperature.toFixed(1);
document.getElementById("humid").textContent = data.humidity.toFixed(1);
document.getElementById("press").textContent = data.pressure.toFixed(1);
document.getElementById("uptime").textContent = data.uptime + "s";
document.getElementById("clients").textContent = data.clients;
document.getElementById("heap").textContent = data.free_heap.toLocaleString();
// Update gauge bars (scale to reasonable ranges)
const tempPct = Math.min(100, Math.max(0, ((data.temperature + 10) / 60) * 100));
const humidPct = Math.min(100, Math.max(0, data.humidity));
const pressPct = Math.min(100, Math.max(0, ((data.pressure - 950) / 100) * 100));
document.getElementById("tempBar").style.width = tempPct + "%";
document.getElementById("humidBar").style.width = humidPct + "%";
document.getElementById("pressBar").style.width = pressPct + "%";
}
if (data.type === "state_update") {
const btn = document.getElementById("relayBtn");
if (data.relay) {
btn.textContent = "ON";
btn.className = "toggle-btn on";
} else {
btn.textContent = "OFF";
btn.className = "toggle-btn off";
}
document.getElementById("ledSlider").value = data.led_brightness;
document.getElementById("ledValue").textContent = data.led_brightness;
}
};
}
function toggleRelay() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: "command",
action: "toggle_relay"
}));
}
}
function setLed(value) {
document.getElementById("ledValue").textContent = value;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: "command",
action: "set_led",
brightness: parseInt(value)
}));
}
}
connect();
</script>
</body>
</html>
)rawliteral";
void setup() {
Serial.begin(115200);
pinMode(RELAY_PIN, OUTPUT);
pinMode(LED_PIN, OUTPUT);
digitalWrite(RELAY_PIN, LOW);
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.printf("\nConnected! Open http://%s in your browser\n",
WiFi.localIP().toString().c_str());
ws.onEvent(onWsEvent);
server.addHandler(&ws);
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send_P(200, "text/html", index_html);
});
server.begin();
}
void loop() {
// Periodically send sensor data to all clients
if (millis() - lastSensorSend >= SENSOR_INTERVAL) {
if (ws.count() > 0) {
notifyClients(getSensorJSON());
}
lastSensorSend = millis();
}
ws.cleanupClients();
}
Flash this firmware, open the printed IP address in any browser, and you have a fully live dashboard. Every gauge updates in real-time. Clicking the relay button or dragging the LED slider sends commands instantly. Open it on your phone and desktop simultaneously — both stay in sync.
Multi-Client Broadcasting
Notice the notifyClients() function uses ws.textAll(). This is the key to multi-client support. When one client toggles the relay, every connected client sees the state change immediately.
void notifyClients(const String &message) {
ws.textAll(message); // Broadcast to ALL connected WebSocket clients
}
For sending to a specific client (like error messages or initial state), use the client pointer:
client->text("{\"type\":\"error\",\"message\":\"Unknown command\"}");
You can also iterate over clients for selective broadcasting:
for (auto &c : ws.getClients()) {
if (c->status() == WS_CONNECTED) {
c->text(message);
}
}
Connection and Disconnection Handling
The WS_EVT_CONNECT and WS_EVT_DISCONNECT events let you track clients. This is useful for:
- Sending initial state to new clients so their UI is correct from the start
- Logging who connects and from where
- Pausing sensor broadcasts when no clients are connected (saves CPU)
The firmware above already implements this pattern:
case WS_EVT_CONNECT:
// Send current state so new client's UI is immediately correct
client->text(getStateJSON());
client->text(getSensorJSON());
break;
And in the loop, we skip broadcasting when nobody is listening:
if (ws.count() > 0) {
notifyClients(getSensorJSON());
}
Performance: How Many Clients Can ESP32 Handle?
The ESP32 has limited RAM (roughly 320 KB total, with around 200 KB available after WiFi). Each WebSocket client consumes memory for the connection state, send/receive buffers, and queued messages.
Practical limits:
| Scenario | Max Clients | Notes |
|---|---|---|
| Simple text messages (<128 bytes) | 6-8 | Comfortable for dashboards |
| JSON sensor data (~200 bytes) | 4-6 | Typical IoT dashboard |
| Large payloads (>1 KB) | 2-3 | Avoid if possible |
| With OTA + SPIFFS active | 3-4 | OTA uses significant RAM |
Tips to maximize client capacity:
- Keep JSON payloads small — abbreviate keys if needed (
tinstead oftemperature) - Call
ws.cleanupClients()in your loop to free disconnected client resources - Set a max client limit:
ws.enable(true)/ws.enable(false)when at capacity - Reduce send frequency — 1 Hz is usually enough for dashboards
- Monitor free heap with
ESP.getFreeHeap()and log warnings below 50 KB
Adding mDNS for Easy Access
Typing an IP address is tedious. With mDNS, you can access your ESP32 at a friendly hostname like http://sensor.local.
#include <ESPmDNS.h>
void setup() {
// ... after WiFi connected ...
if (MDNS.begin("sensor")) {
Serial.println("mDNS: http://sensor.local");
MDNS.addService("http", "tcp", 80);
}
}
Now any device on your local network can reach the dashboard at http://sensor.local — no need to remember IP addresses. This works on macOS and Linux out of the box. On Windows, you may need Bonjour installed (it comes with iTunes).
Security Considerations
An ESP32 on your local network is relatively safe, but for any exposed or sensitive application, consider these measures.
Basic authentication before WebSocket upgrade:
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
if (!request->authenticate("admin", "esp32pass")) {
return request->requestAuthentication();
}
request->send_P(200, "text/html", index_html);
});
Origin checking in WebSocket handler:
case WS_EVT_CONNECT: {
// You can inspect headers during the HTTP upgrade
// For basic setups, ensure the dashboard is only served to local network
IPAddress clientIP = client->remoteIP();
Serial.printf("Client from: %s\n", clientIP.toString().c_str());
break;
}
Additional security practices:
- Use a random WebSocket endpoint path instead of
/ws(security through obscurity, but helps) - Implement a simple token-based auth: send a token as the first WebSocket message, disconnect if invalid
- Never expose the ESP32 directly to the internet — use a VPN or reverse proxy
- Rate-limit incoming commands to prevent abuse
Reconnection Logic
WebSocket connections will drop — WiFi hiccups, ESP32 restarts, phone goes to sleep. Robust reconnection logic on the browser side is essential. The dashboard code above already handles this:
ws.onclose = () => {
document.getElementById("statusText").textContent = "Reconnecting...";
if (!reconnectInterval) {
reconnectInterval = setInterval(() => {
connect(); // Try every 3 seconds
}, 3000);
}
};
Key points for reliable reconnection:
- Visual indicator — always show connection status so users know when data is stale
- Exponential backoff — for production, increase the retry interval: 1s, 2s, 4s, 8s, up to 30s max
- State sync on reconnect — when the connection reopens, request current state immediately
- Avoid duplicate intervals — guard against creating multiple
setIntervaltimers (the code above checksreconnectIntervalbefore creating a new one)
Exponential backoff example:
let retryDelay = 1000;
const MAX_RETRY = 30000;
ws.onclose = () => {
setTimeout(() => {
connect();
retryDelay = Math.min(retryDelay * 2, MAX_RETRY);
}, retryDelay);
};
ws.onopen = () => {
retryDelay = 1000; // Reset on successful connect
};
Common Issues and Memory Leaks
Problem: ESP32 crashes after running for hours
The most common cause is not calling ws.cleanupClients(). Disconnected clients that were not properly cleaned up accumulate and eat memory. Always call it in your loop.
Problem: Messages arrive corrupted or incomplete
WebSocket messages can be fragmented. The AwsFrameInfo check in the event handler ensures we only process complete, single-frame text messages:
if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT)
For messages larger than the default buffer, you need to reassemble fragments — but for IoT dashboards, keep messages small and this is never an issue.
Problem: textAll() causes watchdog resets
If you broadcast large messages to many clients simultaneously, the ESP32 can trigger a watchdog timeout. Solutions: reduce payload size, reduce client count, or send to clients sequentially with small yield() calls between each.
Advanced: OTA Progress via WebSocket
WebSockets are perfect for reporting Over-The-Air update progress. Instead of the user staring at a blank page wondering if the update is working, you can stream progress percentages live.
#include <Update.h>
// In your OTA upload handler:
server.on("/update", HTTP_POST,
[](AsyncWebServerRequest *request) {
bool success = !Update.hasError();
request->send(200, "text/plain", success ? "OK" : "FAIL");
if (success) ESP.restart();
},
[](AsyncWebServerRequest *request, String filename,
size_t index, uint8_t *data, size_t len, bool final) {
if (!index) {
Update.begin((ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000);
}
Update.write(data, len);
// Report progress over WebSocket
int progress = (index + len) * 100 / request->contentLength();
String msg = "{\"type\":\"ota_progress\",\"percent\":" + String(progress) + "}";
ws.textAll(msg);
if (final) {
Update.end(true);
ws.textAll("{\"type\":\"ota_progress\",\"percent\":100,\"status\":\"complete\"}");
}
}
);
On the browser side:
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "ota_progress") {
progressBar.style.width = data.percent + "%";
if (data.status === "complete") {
statusText.textContent = "Update complete! Rebooting...";
}
}
};
Project: Complete Home Sensor Dashboard
Putting it all together, here is what a real deployment looks like:
Hardware:
- ESP32 DevKit (available at wavtron.in)
- BME280 sensor (I2C) for temperature, humidity, and pressure
- Single-channel relay module on GPIO 26
- LED on GPIO 2 (built-in on most ESP32 boards)
Wiring:
- BME280 SDA to GPIO 21, SCL to GPIO 22
- Relay IN to GPIO 26
- Power the BME280 and relay from 3.3V and GND
To use a real BME280 instead of the simulated readings, add the Adafruit BME280 library and replace the readTemperature(), readHumidity(), and readPressure() functions:
#include <Adafruit_BME280.h>
Adafruit_BME280 bme;
void setup() {
// ... WiFi setup ...
if (!bme.begin(0x76)) {
Serial.println("BME280 not found!");
}
}
float readTemperature() { return bme.readTemperature(); }
float readHumidity() { return bme.readHumidity(); }
float readPressure() { return bme.readPressure() / 100.0F; }
Everything else in the firmware stays the same. The dashboard, WebSocket protocol, and control logic are all sensor-agnostic by design.
Summary
WebSockets transform your ESP32 from a slow, poll-based web server into a responsive, real-time platform. The persistent connection means sensor data arrives instantly, control commands execute without delay, and multiple users can share the same live dashboard.
Key takeaways:
- Use AsyncWebServer + AsyncWebSocket for non-blocking WebSocket support
- Design a JSON message protocol with a
typefield for clean routing - Broadcast state changes to all clients so every dashboard stays in sync
- Implement reconnection logic in your JavaScript — connections will drop
- Monitor free heap and call
cleanupClients()to prevent memory leaks - Keep payloads small — aim for under 200 bytes per message
- Limit to 4-6 concurrent clients for reliable operation
The complete firmware above is ready to flash. Grab an ESP32 DevKit and a BME280 sensor, and you will have a real-time home dashboard running in minutes.



