BeginnerESP32ESP32WiFiWeb Server

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.

Circuit Hub20 min read1 views

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

ESP32 WROOM-32 Dev Board (38-pin)
×1Buy
LED (any colour, 5 mm)
×4
220 Ω Resistors
×4
Breadboard + Jumper Wires
×1
USB Micro-B Data Cable
×1

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.

C++
// ── 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

  1. 1Install ESPAsyncWebServer and AsyncTCP from GitHub (search both in Library Manager)
  2. 2Enter your WiFi credentials in SSID and PASSWORD
  3. 3Select board: ESP32 Dev Module, upload speed 921600
  4. 4Open Serial Monitor at 115200 baud after upload — IP address will print
  5. 5Open the IP in your phone browser on the same WiFi network
  6. 6Tap any colour button to toggle — state syncs instantly across all open browser tabs

Related projects