ESP32: WebSocket server, the right protocol for live dashboards
Build a WebSocket server on the ESP32 with ESPAsyncWebServer. Push sensor readings to a browser at 10 Hz without polling, with the ping/pong heartbeat that keeps the connection alive.
The first time I tried to push live sensor readings to a browser
dashboard, I polled every 200 ms with fetch(). It worked, until
I added three more charts. Then the dashboard started missing
readings, the ESP32 was spending half its CPU on answering HTTP
requests, and the browser was generating thousands of requests per
minute for what was, in the end, the same handful of values
changing at 5 Hz.
The right tool for that job is WebSocket. The browser opens one
connection to the ESP32, the connection stays open, and the ESP32
pushes new data whenever it has new data. No polling, no request
overhead, no per-update HTTP setup. This tutorial covers what
WebSocket actually is (it is not “HTTP with extras”), how to
serve it on the ESP32 with ESPAsyncWebServer, the heartbeat that
keeps it alive, and the disconnect-detection gotcha that bites
when a phone goes to sleep and wakes up an hour later.
What WebSocket is vs HTTP
HTTP is a request-response protocol. The client asks, the server answers, the connection closes (or stays open with keep-alive, but the model is still “client asks, server answers”). The server cannot push data the client did not ask for.
WebSocket fixes that. The client and server both upgrade an HTTP connection into a persistent, bidirectional channel. After the upgrade, either side can send a message at any time. The server can push. The client can push. There is no request-response shape anymore.
The thing most people get wrong: WebSocket is not “HTTP with push.” It is a separate framing protocol that rides on top of a TCP connection that started as HTTP. The wire format is different, the semantics are different, the headers you send are different.
The upgrade handshake
WebSocket starts as a normal HTTP request, with a special header that asks the server to upgrade the connection:
GET /ws HTTP/1.1
Host: 192.168.1.42
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
If the server agrees, it sends back:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
The 101 Switching Protocols status is the magic number. After
that, both sides switch to the WebSocket framing protocol. The
HTTP request/response machinery is done.
In practice you do not write this handshake yourself. The
ESPAsyncWebServer library handles it; you register a handler
on a path and the library does the upgrade dance. But knowing
what is happening on the wire is the difference between “my
WebSocket broke and I have no idea why” and “oh, the upgrade
header is wrong.”
The WebServer / ESPAsyncWebServer pattern
The sync WebServer library does not support WebSocket well.
You want ESPAsyncWebServer. It is the same library from the
HTTP server tutorial, with AsyncWebSocket added on top:
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
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\n", client->id());
break;
case WS_EVT_DISCONNECT:
Serial.printf("Client %u disconnected\n", client->id());
break;
case WS_EVT_DATA:
// Handle incoming data
break;
}
}
void setup() {
Serial.begin(115200);
WiFi.begin("ssid", "password");
while (WiFi.status() != WL_CONNECTED) delay(500);
ws.onEvent(onWsEvent);
server.addHandler(&ws);
server.begin();
}
Install via Sketch >> Include Library >> Manage Libraries >> search ESPAsync WebServer by me-no-dev. You also need
AsyncTCP (the underlying TCP library) installed alongside it.
The frame format
A WebSocket message is one or more “frames.” Each frame has a small header (2-14 bytes depending on flags) followed by the payload. The bits you care about:
- Opcode (4 bits): text frame (1), binary frame (2), close (8), ping (9), pong (10). Text and binary are your data. Close is “I am done.” Ping and pong are heartbeats.
- MASK (1 bit): set on frames from client to server. The server must unmask before reading. The library does this for you.
- Payload length: 7 bits for small frames, 16 bits for medium, 64 bits for large. The wire format is the same as HTTP chunked transfer encoding.
You almost never deal with this directly. client->text("hello")
or client->binary(buffer, len) on the ESP32 side, and
event.data on the browser side. The library handles framing.
The ping/pong heartbeat
TCP keep-alive (covered in the TCP sockets tutorial) eventually detects dead connections, but the timeout is long (60 seconds by default). WebSocket has a faster heartbeat: ping/pong.
The server (or client) sends a ping frame. The other side must respond with a pong frame within a timeout. If no pong arrives, the connection is declared dead.
ESPAsyncWebServer does this automatically. Every 30 seconds (by
default) the library sends a ping; if no pong comes back, it
fires a WS_EVT_DISCONNECT event and removes the client. You
do not have to write any of this.
The gotcha: if your handler is blocking (e.g. reading a sensor over slow I2C), the ping might not get sent in time, and the library thinks the connection died. The async pattern is the fix.
Handling multiple clients
AsyncWebSocket keeps a list of connected clients. To broadcast
to all of them:
ws.textAll("hello everyone");
To send to one client:
client->text("just for you");
To iterate and do something with each:
for (AsyncWebSocketClient *c : ws.getClients()) {
if (c->status() == WS_CONNECTED) {
c->text("ping");
}
}
The default max is 4 concurrent clients, set by
#define WS_MAX_QUEUED_MESSAGES and the socket count. For a
dashboard with 1-3 browser tabs open, the default is fine. For a
public dashboard that might have 20 viewers, bump the limit in
the library or accept the cap.
The broadcast pattern (the actual use case)
The typical IoT use case: ESP32 reads a sensor every 100 ms, broadcasts the reading to all connected browsers. The full loop:
unsigned long lastBroadcast = 0;
void loop() {
// AsyncWebServer does its work in the background; no
// explicit handleClient() call.
if (millis() - lastBroadcast > 100) {
lastBroadcast = millis();
float reading = readSensor(); // your sensor read
char buf[64];
snprintf(buf, sizeof(buf), "{\"value\":%.2f}", reading);
ws.textAll(buf);
}
}
That’s the whole pattern. Open the page in a browser, see live
updates without polling. The browser side is a 10-line
WebSocket JavaScript snippet (covered in the dashboard
tutorial, if you want the full client code).
Binary vs text frames
Two payload types:
- Text: UTF-8 string. Use for JSON, plain text, anything string-shaped.
- Binary: raw bytes. Use for compact numeric data, binary protocols, anything that is not a string.
For most IoT, text JSON is fine. The overhead is small (a few
bytes per message) and debugging with mosquitto_sub-equivalent
browser dev tools is trivial. For high-rate sensor streams
(1 kHz+), binary is worth it. A 4-byte float as binary is 4
bytes; as JSON it is 8-15 bytes.
On the ESP32, client->text("...") and client->binary(buf, len) set the opcode. On the browser side, event.data is
either a string or an ArrayBuffer depending on which was sent.
The disconnect-detection gotcha
The most common bug: a phone connects, then the screen turns off and the Wi-Fi goes into low-power mode. The TCP connection silently breaks. The phone never sends a close frame. The ESP32 thinks the client is still there.
The fix is the ping/pong heartbeat. With the default 30-second
ping interval, the ESP32 will detect the dead client within a
minute and fire WS_EVT_DISCONNECT. The client’s slot is freed
and the next broadcast skips it.
If your dashboard has 10 phones that all go to sleep at night,
you can hit the 4-client limit even when “nobody is connected.”
The fix is either raise the limit, or send an explicit close
when the phone tells the page to unload (pagehide event in the
browser).
When to use WebSocket vs polling
Use WebSocket when:
- The server has data to push (sensor readings, alerts, status changes)
- The update rate is faster than 1 Hz (polling at 5+ Hz starts to feel laggy and burns CPU on both ends)
- The dashboard has more than one panel that needs the same live data (one connection feeds all of them)
Use polling (regular HTTP fetch) when:
- The update rate is 1 Hz or slower
- The dashboard is simple (one chart, one value)
- The connection might be flaky (Wi-Fi reconnects, phone sleeping) and you want the request to fail loudly rather than silently
MQTT over WebSocket is the third option for when the dashboard sits behind a broker that already speaks MQTT. The MQTT tutorial covers that path.
What to build next
- A live dashboard with three charts, all fed by one WebSocket
connection. The browser code is a 20-line JavaScript snippet
with
new WebSocket("ws://192.168.1.42/ws")and anonmessagehandler that updates the chart. - A bidirectional control panel: dashboard sends
{"led": "on"}over the WebSocket, ESP32 toggles a GPIO and sends back{"led": "on", "ack": true}. The same connection, both directions. - A multi-client chat between two ESP32s over your local network. Each ESP32 is a WebSocket client to a small Node.js relay. Demonstrates the broadcast pattern at the relay layer.
- The HTTP server tutorial (
esp32-http-server-in-depth) for the request-response pattern, then compare it to this push pattern. Most projects need both.