Blog Tutorial
Serving a Realtime Web UI from an ESP32
Helios needed a control panel: sliders for brightness and speed, a color picker, and a live preview of what the LED matrix is doing right now. Polling an HTTP endpoint every 100 ms from four phones would flatten an ESP32. WebSockets don’t. Here’s the setup that got me a responsive UI on a $4 board, and the two mistakes that cost me a weekend.
Architecture
Three pieces:
- Static assets (HTML/CSS/JS) served from LittleFS, gzip-compressed at build time.
- A WebSocket endpoint (
/ws) usingESPAsyncWebServer, which handles all realtime traffic. - A tiny binary protocol — not JSON — for state updates in both directions.
The browser loads the page once, opens the socket, and everything after that is push.
Baking gzipped assets into flash
The ESP32 has flash to spare but very little RAM, and TLS-less gzip decompression happens in the browser, not the chip. So compress at build time and serve the bytes verbatim with the right header:
# build step: minify, then gzip -9 everything into the data/ dir
npx esbuild src/app.js --bundle --minify --outfile=data/app.js
gzip -9 -k data/app.js && mv data/app.js.gz data/app.js
server.serveStatic("/", LittleFS, "/")
.setDefaultFile("index.html");
// serveStatic detects the gzip magic bytes and adds
// Content-Encoding: gzip automatically — no manual header needed.
My whole UI is 61 KB uncompressed, 14.2 KB gzipped. First paint over 802.11n from the ESP32’s soft-AP: about 180 ms.
The protocol: skip JSON
My first version serialized state as JSON. Parsing ArduinoJson on every slider drag (browsers fire input events at ~60 Hz) pushed one core to 90% and starved the LED render task. The fix was a fixed-layout binary frame:
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | opcode (0x01 = state, 0x02 = preview row) |
| 1 | 1 | brightness (0–255) |
| 2 | 2 | speed, little-endian |
| 4 | 3 | RGB color |
Seven bytes instead of ~90, and decoding is a memcpy into a packed struct. On the browser side, DataView reads it just as cheaply.
Handling four clients
ESPAsyncWebServer will happily accept more connections than the chip can feed. I cap it and rate-limit the preview stream:
ws.onEvent([](AsyncWebSocket *s, AsyncWebSocketClient *c,
AwsEventType t, void *arg, uint8_t *data, size_t len) {
if (t == WS_EVT_CONNECT && s->count() > 4) {
c->close(1013, "server full"); // 1013 = try again later
return;
}
// ...
});
The mistake that cost me the weekend: calling client->binary() from the LED render task without checking client->canSend(). When one phone wandered to the edge of WiFi range, its TX queue backed up, the async TCP task ran out of heap, and the whole board rebooted. Now I drop preview frames for any client with a full queue — a laggy preview beats a crash.
Latency measurements
Measured round-trip (slider drag → LED change → preview frame back) with a timestamp echoed in the frame, 1000 samples per client count:
| Clients | Median RTT | p95 |
|---|---|---|
| 1 | 9 ms | 16 ms |
| 2 | 11 ms | 21 ms |
| 4 | 14 ms | 34 ms |
Preview frames go out at 20 Hz regardless of client count — the broadcast is one buffer shared across sockets, so cost grows with TCP writes, not serialization.
Under 15 ms median with four phones connected is well below what anyone perceives as lag on a slider. The ESP32 spends most of its time idle; the LEDs never stutter. The whole stack fits in 1.1 MB of flash including the filesystem image.
Keep reading
More from the bench
Engineering This Website
How vivaanshah.tech is built: Astro 5 static output on Cloudflare Pages, a dependency-free motion engine, seeded generative circuit art, and a CSP with no unsafe-inline.
Read postTaming Stepper Motor Resonance with TMC2209s
Why my robotic arm sang like a violin at 400 mm/min, and how StealthChop, microstepping and a firmware notch filter fixed it.
Read post