Refreshing a web page to see a new sensor value feels like 2005. Polling every second with JavaScript works, but it wastes bandwidth, adds delay and hammers a small chip with a new HTTP request each time. WebSockets solve this properly: the browser opens one persistent connection to the ESP32, and from then on the ESP32 can push a new reading the instant it exists, while the browser can send commands back over the same link.
This tutorial builds a complete real-time dashboard: an ESP32 reads an analog sensor, pushes the value to every connected browser twice a second, draws a live graph, and lets you switch an LED from the page. It also covers the details that make WebSocket projects reliable in practice: automatic browser reconnection, heartbeats that detect dead connections and sensible limits on clients and message rate.
HTTP Polling vs WebSocket vs MQTT
| HTTP polling | WebSocket | MQTT | |
|---|---|---|---|
| Connection | New request each time | One persistent connection | One persistent connection to a broker |
| Who can send | Browser asks, ESP32 answers | Either side, any time | Any client via the broker |
| Latency | Up to the poll interval | Milliseconds | Milliseconds |
| Overhead per update | High (headers each time) | Very low | Very low |
| Needs extra server | No | No | Yes (broker) |
| Best for | Occasional reads | Live dashboards in a browser | Many devices, automation |
Pick WebSockets when the consumer is a web page and you want live data without a broker. Pick MQTT when many devices and systems share data, and plain HTTP when updates are rare.
How It Works
The browser loads a normal HTML page from the ESP32 on port 80. JavaScript in that page then opens ws://<esp32-ip>:81/. The first handshake is an HTTP request that upgrades to the WebSocket protocol, and after that both sides exchange small text messages freely until one closes. The ESP32 keeps a list of connected clients and can broadcast to all of them.
What You Need
- An ESP32 board
- A potentiometer (10 kΩ) with the wiper on GPIO 34, outer legs on 3.3V and GND (an ADC1 pin, so it keeps working with WiFi, see the ADC guide)
- An LED with a 220 Ω resistor on GPIO 23
- The WebSockets library by Markus Sattler (Library Manager, search "WebSockets")
- A WiFi connection (WiFi tutorial)
The built-in WebServer serves the page on port 80, and WebSocketsServer runs on port 81. Two ports keep the code simple and avoid extra dependencies.
The Complete Sketch
The web page is embedded in the sketch as a raw string, so no filesystem is needed. It draws the graph with plain JavaScript on a canvas, so it also works when the ESP32 has no internet access (no CDN).
#include <WiFi.h>
#include <WebServer.h>
#include <WebSocketsServer.h>
const char* ssid = "YOUR_WIFI_NAME";
const char* password = "YOUR_WIFI_PASSWORD";
const int SENSOR_PIN = 34;
const int LED_PIN = 23;
WebServer server(80);
WebSocketsServer webSocket(81);
bool ledState = false;
unsigned long lastSend = 0;
const char PAGE[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ESP32 Live</title>
<style>
body{font-family:system-ui,sans-serif;background:#111;color:#eee;text-align:center;margin:0;padding:16px}
#val{font-size:3rem;margin:8px 0}
#status{font-size:.9rem;color:#888}
canvas{background:#1b1b1b;border-radius:8px;width:100%;max-width:640px;height:200px}
button{font-size:1.1rem;padding:12px 28px;border:0;border-radius:8px;background:#2d8cff;color:#fff;margin-top:16px}
</style>
</head>
<body>
<h2>ESP32 Real-Time Sensor</h2>
<div id="val">--</div>
<div id="status">connecting...</div>
<canvas id="c" width="640" height="200"></canvas><br>
<button id="btn" onclick="toggleLed()">LED: --</button>
<script>
let ws, retry = 1000, data = [], led = false;
const MAX = 120;
function connect(){
ws = new WebSocket('ws://' + location.hostname + ':81/');
ws.onopen = () => { document.getElementById('status').textContent = 'connected'; retry = 1000; };
ws.onclose = () => {
document.getElementById('status').textContent = 'disconnected, retrying...';
setTimeout(connect, retry);
retry = Math.min(retry * 2, 10000);
};
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.sensor !== undefined) {
document.getElementById('val').textContent = m.sensor;
data.push(m.sensor); if (data.length > MAX) data.shift();
draw();
}
if (m.led !== undefined) {
led = m.led;
document.getElementById('btn').textContent = 'LED: ' + (led ? 'ON' : 'OFF');
}
};
}
function toggleLed(){
if (ws && ws.readyState === 1) ws.send(led ? 'led:0' : 'led:1');
}
function draw(){
const c = document.getElementById('c'), g = c.getContext('2d');
g.clearRect(0, 0, c.width, c.height);
g.strokeStyle = '#2d8cff'; g.lineWidth = 2; g.beginPath();
data.forEach((v, i) => {
const x = i * (c.width / (MAX - 1));
const y = c.height - (v / 4095) * c.height;
i ? g.lineTo(x, y) : g.moveTo(x, y);
});
g.stroke();
}
connect();
</script>
</body>
</html>
)rawliteral";
void sendLedState(int clientNum = -1) {
String msg = String("{\"led\":") + (ledState ? "true" : "false") + "}";
if (clientNum < 0) webSocket.broadcastTXT(msg);
else webSocket.sendTXT(clientNum, msg);
}
void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
switch (type) {
case WStype_CONNECTED:
Serial.printf("Client %u connected\n", num);
sendLedState(num); // tell the new client the current LED state
break;
case WStype_DISCONNECTED:
Serial.printf("Client %u disconnected\n", num);
break;
case WStype_TEXT: {
String cmd = String((char*)payload);
if (cmd == "led:1") ledState = true;
else if (cmd == "led:0") ledState = false;
digitalWrite(LED_PIN, ledState ? HIGH : LOW);
sendLedState(); // tell everyone
break;
}
default:
break;
}
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.print("Open http://");
Serial.println(WiFi.localIP());
server.on("/", []() { server.send_P(200, "text/html", PAGE); });
server.begin();
webSocket.begin();
webSocket.onEvent(onWebSocketEvent);
webSocket.enableHeartbeat(15000, 3000, 2); // ping every 15s, drop dead clients
}
void loop() {
server.handleClient();
webSocket.loop();
if (millis() - lastSend >= 500) {
lastSend = millis();
String msg = String("{\"sensor\":") + analogRead(SENSOR_PIN) + "}";
webSocket.broadcastTXT(msg);
}
}
Upload it, open the printed address in a browser and turn the potentiometer. The number and graph follow instantly. Open the page on a phone as well, and press the LED button on one device: the other device's button updates too, because the ESP32 broadcasts the new state to everyone.
Walking Through the Important Parts
webSocket.loop() must run constantly. It handles incoming frames and connection management. A long delay() in loop() makes the connection stall and eventually drop, so keep timing with millis() as the sketch does.
Messages are tiny JSON strings. {"sensor":2048} is easy to parse in JavaScript and easy to extend later with {"sensor":2048,"temp":24.5}. Keep messages short; the ESP32 has limited RAM and every client multiplies the cost of each broadcast.
The page picks the host automatically. location.hostname means the same page works at any IP address, so you never edit the HTML when the address changes. Still, a fixed address is good practice: see the static IP guide.
New clients get the current state. Sending the LED state in the WStype_CONNECTED handler means a freshly opened page starts correct instead of waiting for the next change.
Reconnect with backoff. The JavaScript onclose handler retries after one second and doubles the delay up to ten seconds. If the ESP32 reboots, every open page reconnects by itself.
Heartbeat. enableHeartbeat(15000, 3000, 2) pings clients every 15 seconds and drops one after two missed pongs. Without it, a phone that walks out of WiFi range can stay listed as "connected" for a long time and waste a client slot.
Sending Several Values and Parsing Commands Properly
Real projects send more than one number. Use ArduinoJson to build the message cleanly:
#include <ArduinoJson.h>
void broadcastReadings(float temp, float hum, int light) {
JsonDocument doc;
doc["temp"] = temp;
doc["hum"] = hum;
doc["light"] = light;
String out;
serializeJson(doc, out);
webSocket.broadcastTXT(out);
}
And accept structured commands instead of ad-hoc strings:
case WStype_TEXT: {
JsonDocument doc;
if (deserializeJson(doc, payload, length) == DeserializationError::Ok) {
if (doc["cmd"] == "led") ledState = doc["value"];
if (doc["cmd"] == "pwm") ledcWrite(LED_PIN, (int)doc["value"]);
}
break;
}
On the browser side, send ws.send(JSON.stringify({cmd:"led", value:true})). Always validate incoming values before they drive hardware, exactly as in the REST API tutorial.
Limits and Good Habits
- Clients: the library defaults to a small maximum (five simultaneous clients). That is plenty for a home dashboard, and each extra client costs RAM.
- Rate: 2 to 10 updates per second is comfortable. Pushing hundreds per second to several clients will starve the rest of your code.
- Only send on change when values rarely move, which saves WiFi airtime and battery.
- Never block inside the event handler. Set a flag and do heavy work in
loop(). - Security: a plain
ws://connection is unencrypted and has no login here. Keep it on your home network. For remote access, use a VPN or a reverse proxy that addswss://and authentication.
Troubleshooting
The page loads but stays on "connecting..." or "disconnected". Port 81 is unreachable. Confirm webSocket.begin() ran, the browser is on the same network, and nothing (such as a VPN or a guest network) blocks the port. Open the browser console (F12) for the exact error.
Values freeze after a minute or two. webSocket.loop() is starved by a long delay() or a blocking call. Replace delays with millis() timing.
It works on a computer but not on https:// pages. A page served over HTTPS cannot open an insecure ws:// connection (mixed content). Serve the dashboard over plain HTTP from the ESP32, or put everything behind a TLS proxy and use wss://.
Random disconnects and reboots. Weak power or weak WiFi. WiFi bursts need a solid supply, and the signal should be better than about -75 dBm (check with WiFi.RSSI()). See the WiFi troubleshooting guide.
Garbled analog values when WiFi is on. The sensor is on an ADC2 pin. Move it to GPIO 32 to 39.
Compile error: WebSocketsServer.h not found. Install the library named WebSockets by Markus Sattler, not a similarly named package.
Multiple tabs open and everything gets slow. Each tab is a client. Close unused ones, or lower the update rate.
Frequently Asked Questions
WebSocket or Server-Sent Events? SSE is one-way (ESP32 to browser) and simpler, but WebSocket also lets the browser send commands over the same connection.
Can I use ESPAsyncWebServer instead? Yes. It includes an AsyncWebSocket class and handles many clients efficiently, at the cost of an extra library dependency and a slightly different callback style.
Can Home Assistant or Node-RED connect to this? Yes, any WebSocket client can. For Home Assistant itself, ESPHome or MQTT are far better integrations.
How do I store the history? Browsers forget everything on refresh. Log readings to an SD card or send them to a database with HTTP POST or MQTT.
What is the next step? Add more sensors, a gauge widget and a settings panel, or turn the same server into a full control surface for your project.