ESP32 WiFi Web Server — Control LEDs from Any Browser
Create a WiFi-hosted web dashboard on ESP32 WROOM-32 using ESPAsyncWebServer. Toggle four GPIO pins from any phone or laptop on your LAN — no cloud, no app, just a URL.
Why ESPAsyncWebServer?
The classic WebServer.h blocks the loop while handling a request. ESPAsyncWebServer runs on FreeRTOS tasks and processes HTTP requests asynchronously, meaning the ESP32 can handle multiple simultaneous clients without latency spikes. It also supports Server-Sent Events (SSE) for real-time push updates — the dashboard refreshes GPIO state without polling.
The HTML dashboard is embedded as a raw string literal in flash memory (PROGMEM), so no SD card or SPIFFS partition is needed.
💡 Tip
Install these libraries via Arduino Library Manager or PlatformIO: **ESPAsyncWebServer** by me-no-dev and **AsyncTCP** by me-no-dev. Both must be installed together — ESPAsyncWebServer depends on AsyncTCP on ESP32.
Components required
Wiring
LEDs connect through 220 Ω resistors from each GPIO to GND:
| Colour | GPIO | |--------|-------| | Red | GPIO 26 | | Green | GPIO 27 | | Blue | GPIO 14 | | Yellow | GPIO 12 |
Note: GPIO 12 bootstraps FLASH voltage on some modules. If the board won't boot, move Yellow to GPIO 13.
// ── ESP32 WiFi Web Server — 4-Channel LED Control ────────────────────────────
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
const char* SSID = "YOUR_WIFI_SSID";
const char* PASSWORD = "YOUR_WIFI_PASSWORD";
const int LED_PINS[] = {26, 27, 14, 13};
const char* LED_NAMES[] = {"Red", "Green", "Blue", "Yellow"};
bool ledState[] = {false, false, false, false};
AsyncWebServer server(80);
AsyncEventSource events("/events");
// ── Embedded HTML dashboard ───────────────────────────────────────────────────
const char HTML[] PROGMEM = R"rawhtml(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ESP32 LED Control</title>
<style>
body { font-family: system-ui, sans-serif; background: #0f172a; color: #f1f5f9;
display: flex; flex-direction: column; align-items: center; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 1.5rem; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; width: 100%; max-width: 360px; }
button { padding: 1.2rem; border-radius: 0.75rem; border: none; font-size: 1rem;
cursor: pointer; transition: opacity .15s; font-weight: 600; }
button:active { opacity: .7; }
.on { background: #22c55e; color: #fff; }
.off { background: #334155; color: #94a3b8; }
</style>
</head>
<body>
<h1>ESP32 LED Control</h1>
<div class="grid" id="grid"></div>
<script>
const LEDS = ["Red","Green","Blue","Yellow"];
const grid = document.getElementById("grid");
let states = [false, false, false, false];
function render() {
grid.innerHTML = "";
LEDS.forEach((name, i) => {
const btn = document.createElement("button");
btn.className = states[i] ? "on" : "off";
btn.textContent = name + (states[i] ? " ON" : " OFF");
btn.onclick = () => {
fetch("/toggle?pin=" + i).then(r => r.json()).then(d => {
states[i] = d.state;
render();
});
};
grid.appendChild(btn);
});
}
// Server-Sent Events for real-time state sync
const es = new EventSource("/events");
es.addEventListener("state", e => {
states = JSON.parse(e.data);
render();
});
render();
</script>
</body>
</html>
)rawhtml";
// ── Build JSON state string ───────────────────────────────────────────────────
String buildStateJSON() {
String j = "[";
for (int i = 0; i < 4; i++) {
j += ledState[i] ? "true" : "false";
if (i < 3) j += ",";
}
j += "]";
return j;
}
void setup() {
Serial.begin(115200);
for (int i = 0; i < 4; i++) {
pinMode(LED_PINS[i], OUTPUT);
digitalWrite(LED_PINS[i], LOW);
}
WiFi.begin(SSID, PASSWORD);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
Serial.println("\nConnected! IP: " + WiFi.localIP().toString());
Serial.println("Open http://" + WiFi.localIP().toString() + " in your browser");
// ── Routes ──────────────────────────────────────────────────────────────────
server.on("/", HTTP_GET, [](AsyncWebServerRequest* req) {
req->send_P(200, "text/html", HTML);
});
server.on("/toggle", HTTP_GET, [](AsyncWebServerRequest* req) {
if (!req->hasParam("pin")) { req->send(400); return; }
int pin = req->getParam("pin")->value().toInt();
if (pin < 0 || pin > 3) { req->send(400); return; }
ledState[pin] = !ledState[pin];
digitalWrite(LED_PINS[pin], ledState[pin] ? HIGH : LOW);
// Push updated state to all SSE clients
events.send(buildStateJSON().c_str(), "state", millis());
String resp = "{"pin":" + String(pin) + ","state":" + (ledState[pin] ? "true" : "false") + "}";
req->send(200, "application/json", resp);
});
events.onConnect([](AsyncEventSourceClient* client) {
client->send(buildStateJSON().c_str(), "state", millis());
});
server.addHandler(&events);
server.begin();
}
void loop() { /* All handled async */ }Live simulation
Steps
- 1Install ESPAsyncWebServer and AsyncTCP from GitHub (search both in Library Manager)
- 2Enter your WiFi credentials in SSID and PASSWORD
- 3Select board: ESP32 Dev Module, upload speed 921600
- 4Open Serial Monitor at 115200 baud after upload — IP address will print
- 5Open the IP in your phone browser on the same WiFi network
- 6Tap any colour button to toggle — state syncs instantly across all open browser tabs