Phase 4: Connectivity 13 min read

WebSockets on ESP32 — Real-Time Bidirectional Communication

Implement WebSockets on ESP32 for real-time bidirectional data between the board and a browser. Covers ESPAsyncWebServer, ws.textAll(), JSON events, and client reconnection.

Updated June 18, 2026

Why WebSockets?

A web server on ESP32 is great for static pages, but once you need live sensor graphs, joystick controls, or real-time status indicators, HTTP polling becomes wasteful. WebSockets upgrade the HTTP handshake into a persistent bidirectional TCP pipe — the ESP32 pushes data the instant it changes, and the browser sends commands back on the same connection.

Libraries Required

Install both via Arduino Library Manager:

  1. ESPAsyncWebServer (by lacamera or me-no-dev)
  2. AsyncTCP (dependency, for ESP32)

Complete WebSocket LED Controller

websocket_led.ino
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

const char* ssid     = "YourSSID";
const char* password = "YourPassword";

AsyncWebServer server(80);
AsyncWebSocket ws("/ws");

const int LED_PIN = 2;
bool ledState = false;

// HTML page served from PROGMEM
const char INDEX_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head>
<meta charset='utf-8'><title>ESP32 WS</title>
<style>body{font-family:sans-serif;max-width:400px;margin:2rem auto}
.btn{padding:.6rem 1.4rem;font-size:1rem;cursor:pointer;border-radius:6px;border:none;color:#fff}
.on{background:#16a34a}.off{background:#dc2626}</style></head><body>
<h1>LED Control</h1>
<p>State: <strong id='state'>--</strong></p>
<button class='btn on' onclick='send("on")'>Turn ON</button>
<button class='btn off' onclick='send("off")'>Turn OFF</button>
<script>
var ws = new WebSocket('ws://' + location.host + '/ws');
ws.onmessage = e => document.getElementById('state').textContent = e.data;
ws.onclose   = () => setTimeout(() => location.reload(), 3000);
function send(cmd) { ws.send(cmd); }
</script></body></html>
)rawliteral";

void onWsEvent(AsyncWebSocket* server, AsyncWebSocketClient* client,
               AwsEventType type, void* arg, uint8_t* data, size_t len) {
  if (type == WS_EVT_CONNECT) {
    Serial.printf("WS client #%u connected\n", client->id());
    client->text(ledState ? "ON" : "OFF");   // send current state on connect
  }
  else if (type == WS_EVT_DISCONNECT) {
    Serial.printf("WS client #%u disconnected\n", client->id());
  }
  else if (type == WS_EVT_DATA) {
    AwsFrameInfo* info = (AwsFrameInfo*)arg;
    if (info->final && info->opcode == WS_TEXT) {
      String msg = "";
      for (size_t i = 0; i < len; i++) msg += (char)data[i];
      if (msg == "on")  { ledState = true;  digitalWrite(LED_PIN, HIGH); }
      if (msg == "off") { ledState = false; digitalWrite(LED_PIN, LOW);  }
      ws.textAll(ledState ? "ON" : "OFF");   // broadcast to all clients
    }
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
  Serial.println("\nIP: " + WiFi.localIP().toString());

  ws.onEvent(onWsEvent);
  server.addHandler(&ws);
  server.on("/", HTTP_GET, [](AsyncWebServerRequest* req) {
    req->send_P(200, "text/html", INDEX_HTML);
  });
  server.begin();
}

void loop() {
  ws.cleanupClients();   // free disconnected client memory
}

Broadcasting Sensor Data Periodically

ws_sensor_broadcast.ino
void loop() {
  ws.cleanupClients();

  static unsigned long last = 0;
  if (millis() - last >= 1000) {
    last = millis();

    // Build JSON payload
    float temp = 20.0 + (analogRead(34) / 4095.0) * 20.0;
    char buf[64];
    snprintf(buf, sizeof(buf),
      "{\"temp\":%.1f,\"uptime\":%lu}", temp, millis() / 1000);

    if (ws.count() > 0) {   // only send if anyone is listening
      ws.textAll(buf);
    }
  }
}

Ping/Pong and Connection Health

WebSocket connections can silently die when a client’s network changes. Configure pings to detect dead connections:

ws_ping.ino
// In onWsEvent, handle the pong:
else if (type == WS_EVT_PONG) {
  Serial.printf("Client #%u pong received\n", client->id());
}

// In loop(), ping all clients every 30 s:
static unsigned long lastPing = 0;
if (millis() - lastPing > 30000) {
  lastPing = millis();
  ws.pingAll();
}

Client-Side Reconnect

Add this JavaScript to any page to automatically reconnect when the ESP32 reboots:

reconnect.js
function connectWS() {
  const ws = new WebSocket('ws://' + location.host + '/ws');
  ws.onopen    = () => console.log('WS connected');
  ws.onmessage = e => updateUI(JSON.parse(e.data));
  ws.onclose   = () => {
    console.log('WS closed — reconnecting in 3 s');
    setTimeout(connectWS, 3000);
  };
  ws.onerror   = () => ws.close();
  return ws;
}
const socket = connectWS();

Frequently Asked Questions

HTTP polling requires the browser to repeatedly request new data (every 1–2 seconds). WebSockets establish a persistent TCP connection so the ESP32 can push data to the browser the instant it changes, with no polling overhead.
ESPAsyncWebServer (by me-no-dev) includes AsyncWebSocket built-in. Install it via the Library Manager along with its dependency AsyncTCP. Both are needed for WebSocket support.
Practically 4–8 concurrent WebSocket clients on a typical ESP32 before heap pressure causes disconnects. The default ESPAsyncWebServer WebSocket limit is defined by WS_MAX_QUEUED_MESSAGES (50 by default).
Call ws.textAll("your data string") from anywhere in your sketch. This queues the message for transmission to every currently connected client in a single call.
Yes. Use ws.binaryAll(uint8_t* data, size_t len) to broadcast binary frames to all clients, or ws.binary(client_id, data, len) for a specific client. This is useful for sending sensor arrays or image data.
Listen for the WS_EVT_DISCONNECT event in your onEvent callback. Clean up any per-client state keyed by client->id() in that handler. ESPAsyncWebServer calls this event even on timeout-based disconnections.
Async delivery does not guarantee order when messages are queued faster than the TCP stack can send them. For ordered delivery, use a sequence number in each message and sort on the client side, or throttle the send rate.
The arduinoWebSockets library (by Links2004) provides a standalone WebSocket client and server without the full AsyncWebServer framework. It is lighter but has fewer features.
Add reconnect logic in the browser: on the WebSocket close event, wait 2–5 seconds then call new WebSocket(url) again. Repeat until connected.
By default ESPAsyncWebServer limits WebSocket frames to 1436 bytes (one TCP segment). Larger messages are split into fragments. The server reassembles them transparently, but keep individual payloads under 4 KB to avoid heap issues.

Projects to Build

Put this knowledge to work — try one of these hands-on projects.