Guides · 16 min read

ESP32 Async Web Server with WebSocket: Real-Time Control Panel

ESP32 async web server with WebSocket: switch a relay and watch live sensor data without reloading. Setup, PROGMEM vs LittleFS, esp32.local, password and fixes.

An ESP32 async web server, built with the ESPAsyncWebServer library, turns your ESP32 into a fast web server that can answer several phones and computers at the same time. With the WebSocket support that comes with the library, the browser and the board keep a permanently open line: press the button on the page and the relay clicks instantly, and when the temperature changes the number on the page updates by itself, without reloading. Every other device that has the same panel open sees your change within the same second. In short, this pair is the most practical way to build your own smart home panel inside your home network, without any cloud service.

In this guide you'll first see how an asynchronous server differs from the classic WebServer library and why WebSocket is faster than plain HTTP requests. Then you'll install the right libraries, run the simplest possible server and build a real-time control panel with a relay and a DHT22 sensor. After that you'll learn how to keep the HTML in PROGMEM or LittleFS, use esp32.local instead of an IP address, handle multiple clients, add a simple password to the panel and fix the most common compile and connection errors. The code is written to work with both the 2.x and 3.x versions of the ESP32 core in the Arduino IDE.

ESP32 development board for a web server project
ESP32 DevKit: thanks to its Wi-Fi chip it runs a web server on its own; the panel you open on your phone comes straight from this board's flash memory.

What is the ESP32 async web server and how does it work?

The WebServer.h library that ships with the ESP32 core is synchronous: incoming requests are only handled, one at a time, when server.handleClient() is called inside loop(). If loop() contains a 2-second delay(), the page waits 2 seconds too; if two people connect at once, one waits for the other to finish. ESPAsyncWebServer, on the other hand, runs in a separate FreeRTOS task (async_tcp) created by the AsyncTCP library. When a request arrives, its headers are parsed in the background, the function you wrote (the callback) is called once the request is ready, and the response is sent in the background as well. Meanwhile loop() carries on with its own work. This lets several connections stay open at once and makes always-open connections such as WebSocket and Server-Sent Events (SSE) possible. If you're curious about the ESP32's two cores and tasks, take a look at the ESP32 FreeRTOS article.

FeatureWebServer.h (synchronous)ESPAsyncWebServer (asynchronous)
InstallationShips with the ESP32 core2 libraries from the Library Manager
Request handlingOne by one via handleClient() in loop()In the background async_tcp task, several at once
delay() inside loop()Delays the page responseDoesn't affect the server
WebSocketNone; needs a separate library (e.g. the WebSockets library by Markus Sattler)Built in (AsyncWebSocket)
Server-Sent EventsNoneBuilt in (AsyncEventSource)
Serving static filesWith serveStaticserveStatic, automatic .gz support and cache control
Watch out forThe easiest to learnNo delay() or long work inside callbacks

What is WebSocket and how is it different from an HTTP request?

In a normal HTTP request the browser opens a connection, says "give me this page", receives the response and the conversation ends. The server can't tell the browser anything on its own; to see a new value, the browser has to ask again. WebSocket (RFC 6455) is a handshake that starts as HTTP: the browser adds an Upgrade: websocket header to its request, the ESP32 accepts with a 101 Switching Protocols response and the same TCP connection stays open. From then on both sides can send a message whenever they like (full-duplex). Each message carries only 2 to 14 bytes of overhead, whereas every HTTP request carries hundreds of bytes of headers. That's why WebSocket messages arrive within milliseconds and why it's the most efficient method for panels that send several updates per second. Here's how the four ways of keeping a page up to date with sensor values compare:

MethodHow it worksLatency and overheadWhen to use it
Page refresh (meta refresh)The browser downloads the whole page again every few secondsHigh; the page flickers every timeAvoid it
Polling with fetchJavaScript requests a small JSON every 1–2 secondsMedium; every request carries new headersA single value that rarely changes
Server-Sent Events (SSE)The server pushes one-way events over an open connectionLow; server to browser onlyMonitoring-only dashboards
WebSocketTwo-way messaging over an open connectionLowest; frames of a few bytesButtons, sliders and live data together

Required libraries: installing ESPAsyncWebServer and AsyncTCP

  1. Make sure the ESP32 board package (Espressif Systems) is installed in the Arduino IDE; the setup steps are in What Is the ESP32?
  2. Open the Library Manager (Ctrl+Shift+I) and search for ESPAsyncWebServer. Install the ESP Async WebServer library whose author is ESP32Async.
  3. This library doesn't declare its dependency to the Library Manager, so the IDE won't offer extra libraries: install the Async TCP library, also published by ESP32Async, separately. On an ESP8266 you need ESP32Async's ESP Async TCP library (ESPAsyncTCP.h) instead.
  4. For the panel example, install Adafruit's DHT sensor library and the Adafruit Unified Sensor dependency it asks for. If this is your first time installing a library, see the guide on adding libraries to Arduino.
  5. If your Documents/Arduino/libraries folder contains ESPAsyncWebServer-master, AsyncTCP-master or ESPAsyncWebSrv folders added from a ZIP in the past, delete them. Two copies of the same library make the compiler pick the wrong one.
Most older tutorials online point to the original ESPAsyncWebServer library on the me-no-dev account. That library is no longer maintained and doesn't compile with version 3.x of the ESP32 core; the build stops with the error 'mbedtls_md5_starts_ret' was not declared in this scope. The fix is to delete the old copy and install the ESP32Async versions. In the new version send_P() is also deprecated: on the ESP32 you can send a PROGMEM array directly with request->send(200, "text/html", index_html).

First step: the simplest server with AsyncWebServer

Before moving on to the panel, let's see how the library works with a small example. The code below connects the ESP32 to your home network, shows a greeting on the home page and switches the built-in LED on and off through the /led?durum=1 and /led?durum=0 addresses. Upload it, open the Serial Monitor at 115200 baud and type the address it shows into the browser of a phone on the same Wi-Fi. If the board won't join the network, use the diagnostic code in the ESP32 Wi-Fi not connecting guide.

C++ (Arduino IDE)
1#include <WiFi.h>
2#include <AsyncTCP.h>
3#include <ESPAsyncWebServer.h>
4
5const char* ssid = "AgAdi";          // Wi-Fi adın / your Wi-Fi name
6const char* password = "Sifre123";   // Wi-Fi şifren / your Wi-Fi password
7const int LED_PIN = 2;               // çoğu DevKit'te dahili LED / onboard LED on most DevKits
8
9AsyncWebServer server(80);           // 80 = standart HTTP portu / standard HTTP port
10bool ledAcik = false;
11
12void setup() {
13  Serial.begin(115200);
14  pinMode(LED_PIN, OUTPUT);
15
16  WiFi.mode(WIFI_STA);
17  WiFi.begin(ssid, password);
18  while (WiFi.status() != WL_CONNECTED) {
19    delay(500);
20    Serial.print(".");
21  }
22  Serial.println();
23  Serial.print("Adres / address: http://");
24  Serial.println(WiFi.localIP());
25
26  // Ana sayfa / home page
27  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
28    request->send(200, "text/plain", "Merhaba, ben ESP32 / Hello from ESP32");
29  });
30
31  // /led?durum=1 yakar, /led?durum=0 söndürür / turns the LED on or off
32  server.on("/led", HTTP_GET, [](AsyncWebServerRequest *request) {
33    if (request->hasParam("durum")) {
34      ledAcik = request->getParam("durum")->value() == "1";
35      digitalWrite(LED_PIN, ledAcik ? HIGH : LOW);
36    }
37    request->send(200, "text/plain", ledAcik ? "LED: ON" : "LED: OFF");
38  });
39
40  // Tanımsız adresler / unknown addresses
41  server.onNotFound([](AsyncWebServerRequest *request) {
42    request->send(404, "text/plain", "Sayfa yok / Not found");
43  });
44
45  server.begin();
46}
47
48void loop() {
49  // Sunucu arka planda çalışır, loop() boş kalabilir / the server runs in the background
50}
CommandWhat it does
AsyncWebServer server(80)Creates the server listening on HTTP port 80
server.on("/led", HTTP_GET, ...)Registers the function (callback) to run for an address and request type
request->hasParam("durum")Checks whether the address contains a ?durum=... parameter
request->getParam("durum")->value()Returns the parameter's value as a String
request->send(code, type, content)Sends the response: 200 OK, 404 not found; type text/plain, text/html or application/json
server.onNotFound(...)Runs for every address that isn't defined
server.begin()Starts the server; call it after all the server.on() lines
An empty loop() isn't a mistake; the server runs in its own task. On the contrary, don't use delay(), long while loops or requests that fetch data from the internet inside callbacks. The async_tcp task gets stuck meanwhile, and a few seconds later the ESP32 restarts with the message Task watchdog got triggered. Hand long jobs over to loop() with a flag variable, and do your timing with millis().

Parts and wiring for the control panel

In the panel example you'll switch a lamp or a fan with a relay module and measure temperature and humidity with a DHT22. You'll need an ESP32 DevKit, a single-channel 5 V relay module, a DHT22 (or DHT11) sensor module, a breadboard and jumper wires. When choosing a relay, prefer modules with an optocoupler. The DHT22 measures from −40 to 80 °C with ±0.5 °C accuracy and can be read at most once every 2 seconds; the DHT11 is cheaper but gives ±2 °C accuracy over 0–50 °C. For sensor details see the DHT11 temperature and humidity sensor article, and for your board's pin map the Pinout tool.

Module pinESP32 DevKitESP8266 NodeMCUNote
Relay VCCVIN (5 V)VIN or VU (5 V)The relay coil needs 5 V; it's powered from USB
Relay GNDGNDGNDCommon ground
Relay INGPIO 26D1 (GPIO 5)Most modules switch on with a LOW signal
DHT22 VCC (+)3V33V3Powered from 3.3 V, the data line is 3.3 V too
DHT22 DATA (OUT)GPIO 4D2 (GPIO 4)Bare sensor: 10 kΩ resistor between DATA and VCC
DHT22 GND (−)GNDGNDCommon ground
The ESP32's pins output 3.3 V. Some 5 V relay modules don't recognise this level as HIGH, so the relay never releases; with such a module use a 3.3 V compatible relay, a module with a JD-VCC jumper or a transistor in between. If you're going to switch mains voltage (230 V or 120 V) with the relay, wire it with the power off, don't leave screw terminals exposed and, if you're unsure, test first with a low-voltage load (an LED strip or a 12 V fan). Details are in What Is a Relay Module?

Real-time control panel: relay, DHT22 and WebSocket

Now for the main project. The code below is a single file: the HTML, CSS and JavaScript live inside it as a PROGMEM array. When the page opens, the browser opens a WebSocket connection to /ws. Every 2 seconds the ESP32 reads the DHT22 and sends the temperature, humidity, relay state and number of connected devices to every browser in a single JSON message. When you press the button, the browser sends the message toggle, the ESP32 flips the relay and again tells everyone the new state. Change the network name and password, upload, then open http://esp32.local or the IP address from the Serial Monitor in your browser.

C++ (Arduino IDE)
1#include <WiFi.h>
2#include <AsyncTCP.h>
3#include <ESPAsyncWebServer.h>
4#include <ESPmDNS.h>
5#include <DHT.h>
6
7const char* ssid = "AgAdi";          // Wi-Fi adın / your Wi-Fi name
8const char* password = "Sifre123";   // Wi-Fi şifren / your Wi-Fi password
9
10const int ROLE_PIN = 26;             // röle modülü IN / relay module IN
11const bool ROLE_AKTIF_LOW = true;    // çoğu modül LOW ile çeker / most modules switch on LOW
12const int DHT_PIN = 4;               // DHT22 veri pini / DHT22 data pin
13
14DHT dht(DHT_PIN, DHT22);             // DHT11 için DHT11 yaz / write DHT11 for a DHT11
15AsyncWebServer server(80);
16AsyncWebSocket ws("/ws");            // WebSocket adresi / WebSocket path
17
18bool roleAcik = false;
19float sicaklik = NAN;
20float nem = NAN;
21volatile bool gonder = false;        // loop() yayın yapsın mı? / should loop() broadcast?
22unsigned long sonOlcum = 0;
23unsigned long sonTemizlik = 0;
24
25// Tarayıcıya giden sayfa / the page sent to the browser
26const char index_html[] PROGMEM = R"rawliteral(
27<!DOCTYPE html>
28<html lang="tr">
29<head>
30<meta charset="utf-8">
31<meta name="viewport" content="width=device-width, initial-scale=1">
32<title>ESP32 Panel</title>
33<style>
34body{font-family:sans-serif;background:#0f172a;color:#e2e8f0;text-align:center;margin:0;padding:16px}
35.kart{background:#1e293b;border-radius:12px;padding:16px;margin:12px auto;max-width:320px}
36.deger{font-size:2.4em;font-weight:bold}
37button{font-size:1.2em;padding:14px 32px;border:0;border-radius:8px;color:#fff;width:100%}
38.acik{background:#16a34a}.kapali{background:#475569}
39</style>
40</head>
41<body>
42<h2>ESP32 Panel</h2>
43<p id="baglanti">Bağlanıyor / Connecting...</p>
44<div class="kart">Sıcaklık / Temperature<div class="deger"><span id="sicaklik">--</span> &deg;C</div></div>
45<div class="kart">Nem / Humidity<div class="deger"><span id="nem">--</span> %</div></div>
46<div class="kart">Röle / Relay<p><button id="dugme" class="kapali" onclick="degistir()">KAPALI / OFF</button></p></div>
47<p>Bağlı cihaz / Clients: <span id="istemci">0</span></p>
48<script>
49let ws;
50const $ = (id) => document.getElementById(id);
51function baglan() {
52  ws = new WebSocket('ws://' + location.host + '/ws');
53  ws.onopen = () => { $('baglanti').textContent = 'Bağlı / Connected'; };
54  ws.onclose = () => {
55    $('baglanti').textContent = 'Koptu, yeniden deneniyor / Reconnecting...';
56    setTimeout(baglan, 2000);
57  };
58  ws.onmessage = (e) => {
59    const d = JSON.parse(e.data);
60    $('sicaklik').textContent = d.sicaklik === null ? '--' : d.sicaklik.toFixed(1);
61    $('nem').textContent = d.nem === null ? '--' : d.nem.toFixed(0);
62    $('istemci').textContent = d.istemci;
63    $('dugme').textContent = d.role ? 'AÇIK / ON' : 'KAPALI / OFF';
64    $('dugme').className = d.role ? 'acik' : 'kapali';
65  };
66}
67function degistir() {
68  if (ws.readyState === WebSocket.OPEN) ws.send('toggle');
69}
70baglan();
71</script>
72</body>
73</html>
74)rawliteral";
75
76void roleAyarla(bool acik) {
77  roleAcik = acik;
78  digitalWrite(ROLE_PIN, ROLE_AKTIF_LOW ? !acik : acik);
79}
80
81// Durumu JSON metnine çevir / build the state as JSON text
82String durumJson() {
83  String s = "{\"role\":";
84  s += roleAcik ? "true" : "false";
85  s += ",\"sicaklik\":";
86  s += isnan(sicaklik) ? String("null") : String(sicaklik, 1);
87  s += ",\"nem\":";
88  s += isnan(nem) ? String("null") : String(nem, 0);
89  s += ",\"istemci\":";
90  s += (unsigned long)ws.count();
91  s += "}";
92  return s;
93}
94
95// WebSocket olayları (async_tcp görevinde çalışır) / WebSocket events (run in the async_tcp task)
96void wsOlay(AsyncWebSocket *sunucu, AsyncWebSocketClient *client, AwsEventType type,
97            void *arg, uint8_t *data, size_t len) {
98  switch (type) {
99    case WS_EVT_CONNECT:
100      Serial.printf("Istemci #%u baglandi / connected: %s\n",
101                    (unsigned)client->id(), client->remoteIP().toString().c_str());
102      gonder = true;                 // yeni gelen güncel durumu alsın / newcomer gets the state
103      break;
104    case WS_EVT_DISCONNECT:
105      Serial.printf("Istemci #%u ayrildi / disconnected\n", (unsigned)client->id());
106      gonder = true;
107      break;
108    case WS_EVT_DATA: {
109      AwsFrameInfo *info = (AwsFrameInfo *)arg;
110      // Tek parça, tam bir metin mesajı mı? / a complete single-frame text message?
111      if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
112        if (len == 6 && memcmp(data, "toggle", 6) == 0) {
113          roleAyarla(!roleAcik);
114          gonder = true;             // tüm ekranlar eşitlensin / sync every screen
115        }
116      }
117      break;
118    }
119    default:                         // PONG, ERROR vb. / PONG, ERROR etc.
120      break;
121  }
122}
123
124void setup() {
125  Serial.begin(115200);
126  pinMode(ROLE_PIN, OUTPUT);
127  roleAyarla(false);                 // açılışta röle kapalı / relay off at start
128  dht.begin();
129
130  WiFi.mode(WIFI_STA);
131  WiFi.begin(ssid, password);
132  Serial.print("Baglaniyor / connecting");
133  while (WiFi.status() != WL_CONNECTED) {
134    delay(500);
135    Serial.print(".");
136  }
137  WiFi.setSleep(false);              // daha hızlı tepki / lower latency
138  Serial.println();
139  Serial.print("http://");
140  Serial.println(WiFi.localIP());
141
142  if (MDNS.begin("esp32")) {         // http://esp32.local
143    MDNS.addService("http", "tcp", 80);
144    Serial.println("http://esp32.local");
145  }
146
147  ws.onEvent(wsOlay);
148  server.addHandler(&ws);
149
150  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
151    request->send(200, "text/html", index_html);
152  });
153  server.onNotFound([](AsyncWebServerRequest *request) {
154    request->send(404, "text/plain", "Sayfa yok / Not found");
155  });
156  server.begin();
157}
158
159void loop() {
160  if (millis() - sonOlcum >= 2000) {       // DHT22 en fazla 2 sn'de bir / DHT22: once per 2 s
161    sonOlcum = millis();
162    float t = dht.readTemperature();
163    float h = dht.readHumidity();
164    if (!isnan(t) && !isnan(h)) {          // hatalı okumayı atla / skip failed reads
165      sicaklik = t;
166      nem = h;
167    }
168    gonder = true;
169  }
170
171  if (gonder) {
172    gonder = false;
173    ws.textAll(durumJson());               // tüm tarayıcılara yolla / send to every browser
174  }
175
176  if (millis() - sonTemizlik >= 1000) {
177    sonTemizlik = millis();
178    ws.cleanupClients();                   // kopuk istemcileri temizle / drop stale clients
179  }
180}

How does the code work?

  • AsyncWebSocket ws("/ws") creates the WebSocket endpoint and server.addHandler(&ws) attaches it to the server. The address in the JavaScript must match this path exactly.
  • The wsOlay() function is called on every connection, disconnection and incoming message. WS_EVT_CONNECT means a new client, WS_EVT_DISCONNECT a client leaving and WS_EVT_DATA an incoming message.
  • AwsFrameInfo carries information about the incoming frame. Checking final, index == 0 and len confirms that the message arrived complete in a single frame, and WS_TEXT that it's text. Short commands always fit into one frame.
  • The incoming data is compared together with its length using memcmp(), so you don't depend on whether there's a null character at the end of the data.
  • Because the callbacks run in the async_tcp task, they only flip the relay and raise the gonder flag. The actual sending happens in loop() with ws.textAll(), so every message leaves from one place, in order.
  • ws.cleanupClients() is called once a second: it frees the memory of dropped connections and closes the oldest connection when the number of clients exceeds the limit.
  • On the JavaScript side, the onclose event reconnects after 2 seconds. Even if the ESP32 restarts or Wi-Fi drops for a moment, the page recovers by itself. Because the address is built from location.host, the page works with both the IP and esp32.local.
  • WiFi.setSleep(false) turns off Wi-Fi power saving. The default modem sleep can add delays of up to several hundred milliseconds to responses; turning it off on a mains-powered panel makes the buttons feel instant.

Choosing JSON as the message format makes life easy: the browser turns the incoming text into an object in one line with JSON.parse(). A sample message looks like this: {"role":true,"sicaklik":23.4,"nem":48,"istemci":2}. When the sensor can't be read, the value is sent as null and the page shows "--"; if a failed reading went out as NaN, the invalid JSON would stop the page from updating at all. Since each message is only about 60 bytes, even several updates per second don't load the network. For more complex messages you can switch to the ArduinoJson library, but for a panel this size, joining Strings is more than enough. You can also try the same code unchanged with an LED or a buzzer in place of the relay.

Where should the HTML live: PROGMEM or LittleFS?

In the panel code, the page sits in a raw string literal written as R"rawliteral( ... )rawliteral". This syntax lets you use quotes and line breaks without escape characters. On AVR-based Arduinos, PROGMEM is needed to keep text in flash instead of RAM; on the ESP32, constant arrays are read from flash anyway, so PROGMEM is only written for compatibility and does no harm. As the page grows, editing HTML inside the code gets awkward; at that point it's tidier to move the files to the LittleFS file system in flash.

CriterionPROGMEM (inside the code)LittleFS (file system)
SetupNo extra step, a single fileNeeds a data folder and an upload plugin
EditingHTML inside a C++ arraySeparate index.html, style.css and app.js files
UpdatingYou re-upload the sketch after every changeYou only re-upload the file system
SizeIdeal for pages of a few KBAbout 1.5 MB of space with the default partitioning
Images and fontsNot practicalIcons, images and .gz files can be served
When?First tests and small panelsMulti-page or growing interfaces

Serving HTML, CSS and JavaScript files with LittleFS

  1. Inside the sketch folder (the folder containing the .ino file), create a folder named data and save the HTML between R"rawliteral( and )rawliteral" in the panel code as data/index.html. If you like, you can split the CSS and JavaScript into separate files.
  2. Arduino IDE 2 doesn't support the old ESP32 Sketch Data Upload menu. Instead, download the .vsix file of the arduino-littlefs-upload plugin and put it in C:\Users\(your username)\.arduinoIDE\plugins on Windows or ~/.arduinoIDE/plugins on macOS and Linux (create the folder if it doesn't exist), then restart the IDE. The plugin needs IDE 2.2.1 or newer.
  3. Close the Serial Monitor if it's open, then open the command palette with Ctrl+Shift+P (Cmd+Shift+P on macOS) and choose Upload LittleFS to Pico/ESP8266/ESP32. The files are written to the file system partition in flash.
  4. Make sure a partition scheme with a file system partition is selected under Tools > Partition Scheme (for example Default 4MB with spiffs). LittleFS uses this partition, which is named spiffs.
  5. Upload the code below as usual. As long as the Erase All Flash option in the Tools menu is disabled, uploading a sketch doesn't wipe the file system; when you change the files, you only need to repeat step 3.
C++ (Arduino IDE)
1#include <WiFi.h>
2#include <AsyncTCP.h>
3#include <ESPAsyncWebServer.h>
4#include <LittleFS.h>
5
6const char* ssid = "AgAdi";          // Wi-Fi adın / your Wi-Fi name
7const char* password = "Sifre123";   // Wi-Fi şifren / your Wi-Fi password
8
9AsyncWebServer server(80);
10
11void setup() {
12  Serial.begin(115200);
13
14  if (!LittleFS.begin(true)) {       // true: bağlanamazsa biçimlendir / format if mount fails
15    Serial.println("LittleFS hatasi / LittleFS error");
16    return;
17  }
18
19  WiFi.mode(WIFI_STA);
20  WiFi.begin(ssid, password);
21  while (WiFi.status() != WL_CONNECTED) {
22    delay(500);
23  }
24  Serial.println(WiFi.localIP());
25
26  // data/ klasörünün içeriğini sun / serve the contents of the data/ folder
27  server.serveStatic("/", LittleFS, "/")
28        .setDefaultFile("index.html")    // varsayılan index.htm'dir / default is index.htm
29        .setCacheControl("max-age=600"); // tarayıcı 10 dk önbelleğe alsın / cache for 10 min
30
31  server.onNotFound([](AsyncWebServerRequest *request) {
32    request->send(404, "text/plain", "Sayfa yok / Not found");
33  });
34  server.begin();
35}
36
37void loop() {
38}
When a file is requested, serveStatic() first looks for a compressed version with the same name and a .gz extension. If you compress index.html with gzip and upload it as index.html.gz, the page usually shrinks to between a third and a quarter of its size and loads faster. When a folder address is requested the default file name is index.htm, so don't forget the setDefaultFile("index.html") line. If you use the library's template feature, placeholders such as %DEGER% in the page get replaced; in that case you have to write percent signs in your CSS, such as width:100%, as %%.

mDNS: esp32.local instead of an IP address

Your router may give the ESP32 a different IP address every time it starts, and numbers like 192.168.1.47 are hard to remember. mDNS (multicast DNS) lets a device announce its own name on the local network. Thanks to the MDNS.begin("esp32") line in the panel code, devices on the same network can open the page at http://esp32.local, and MDNS.addService("http", "tcp", 80) tells network browser apps that there's a web server here. Don't add ".local" to the name, and use only lowercase letters, digits and hyphens. If you use several ESP32s, give each one a different name (kitchen, living-room and so on), because two devices with the same name interfere with each other. mDNS isn't equally supported on every system:

System.local supportIf it doesn't work
iPhone, iPad, macOSBuilt in (Bonjour)Make sure you're on the same Wi-Fi network
Windows 10 and 11Usually works on up-to-date versionsInstall the Bonjour service or use the IP
LinuxWorks if avahi-daemon is installedInstall the avahi-daemon and nss-mdns packages
AndroidDepends on the version and browserUse the IP address or set a DHCP reservation on the router

Multiple clients: phone, tablet and computer at the same time

The best-loved thing about WebSocket is that everyone who opens the panel sees the same state. When you switch the relay on from your phone, the ESP32 sends the new state to every connection with textAll(), and the tab on your computer turns green at the same moment. Every tab is a separate client: open three tabs in the same browser and the counter shows 3. By default the library allows 8 simultaneous WebSocket clients on the ESP32 and 4 on the ESP8266 (DEFAULT_MAX_WS_CLIENTS). This limit isn't enforced automatically; when cleanupClients() is called, the oldest connection over the limit is closed. Here's what else to keep in mind:

  • When a phone goes to sleep: a phone whose screen turns off may not close the connection properly. Such half-open connections are cleaned up over time thanks to cleanupClients() and regular data sending.
  • Sending rate: up to 32 messages can wait in the queue for each client (8 on the ESP8266). If you call textAll() on every pass of loop(), the queue fills up and new messages are silently dropped; set Tools > Core Debug Level to Warn or a more detailed level and you'll see the warning Too many messages queued in the Serial Monitor. Send a value only when it changes, or a few times per second.
  • A newly connected client: send the current state to every page that connects right away; otherwise the user stares at an empty panel until the first reading. In the panel code, the flag raised in WS_EVT_CONNECT does this.
  • Replying to a single client: to answer only the browser that asked rather than everyone, use client->text("..."); client->id() gives you the client's number.
  • Memory: every open connection holds a TCP socket and buffer memory. The ESP32's 520 KB of RAM is more than enough for a few clients, but printing the free memory now and then with ESP.getFreeHeap() helps you catch leaks early.

Simple password protection

Anyone on your home network who knows the panel's address can switch the relay. With the library's request->authenticate() and request->requestAuthentication() functions you can put a username and password on the page; the browser opens its own login dialog and remembers the details for the session. The default method is Digest authentication, which means the password doesn't travel across the network in plain text. Protecting only the page isn't enough, though: the WebSocket address is a separate door, and someone could connect straight to ws://ip/ws. The example below closes that door too: on every boot the ESP32 generates a random key, hands it only to a logged-in browser at /anahtar and uses setFilter() to reject WebSocket connections that don't bring the right key.

C++ (Arduino IDE)
1#include <WiFi.h>
2#include <AsyncTCP.h>
3#include <ESPAsyncWebServer.h>
4
5const char* ssid = "AgAdi";          // Wi-Fi adın / your Wi-Fi name
6const char* password = "Sifre123";   // Wi-Fi şifren / your Wi-Fi password
7const char* KULLANICI = "admin";     // panel kullanıcı adı / panel username
8const char* PAROLA = "Gizli-2026";   // panel parolası / panel password
9const int LED_PIN = 2;
10
11AsyncWebServer server(80);
12AsyncWebSocket ws("/ws");
13String wsAnahtar;                    // her açılışta yeni anahtar / a new key on every boot
14bool ledAcik = false;
15
16const char index_html[] PROGMEM = R"rawliteral(
17<!DOCTYPE html>
18<html><head><meta charset="utf-8">
19<meta name="viewport" content="width=device-width, initial-scale=1"></head>
20<body style="font-family:sans-serif;text-align:center">
21<h2>Korumali panel / Protected panel</h2>
22<button style="font-size:1.4em;padding:12px 32px" onclick="ws && ws.send('toggle')">LED</button>
23<p id="durum">...</p>
24<script>
25let ws;
26fetch('/anahtar').then(r => r.text()).then(k => {
27  ws = new WebSocket('ws://' + location.host + '/ws?k=' + k);
28  ws.onmessage = (e) => { document.getElementById('durum').textContent = e.data; };
29});
30</script>
31</body></html>
32)rawliteral";
33
34bool girisVar(AsyncWebServerRequest *request) {
35  return request->authenticate(KULLANICI, PAROLA);
36}
37
38void setup() {
39  Serial.begin(115200);
40  pinMode(LED_PIN, OUTPUT);
41
42  WiFi.mode(WIFI_STA);
43  WiFi.begin(ssid, password);
44  while (WiFi.status() != WL_CONNECTED) {
45    delay(500);
46  }
47  Serial.println(WiFi.localIP());
48
49  // Wi-Fi açıkken random() donanım üretecini kullanır / random() uses the hardware RNG
50  wsAnahtar = String(random(0x7FFFFFFF), HEX) + String(random(0x7FFFFFFF), HEX);
51
52  // Sayfa: tarayıcı kullanıcı adı ve parola sorar / page: the browser asks for login
53  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
54    if (!girisVar(request)) return request->requestAuthentication();
55    request->send(200, "text/html", index_html);
56  });
57
58  // Anahtar yalnız giriş yapana verilir / the key is given only after login
59  server.on("/anahtar", HTTP_GET, [](AsyncWebServerRequest *request) {
60    if (!girisVar(request)) return request->requestAuthentication();
61    request->send(200, "text/plain", wsAnahtar);
62  });
63
64  // Doğru anahtarı getirmeyen bağlantıyı reddet / reject sockets without the right key
65  ws.setFilter([](AsyncWebServerRequest *request) {
66    return request->hasParam("k") && request->getParam("k")->value() == wsAnahtar;
67  });
68
69  ws.onEvent([](AsyncWebSocket *sunucu, AsyncWebSocketClient *client, AwsEventType type,
70                void *arg, uint8_t *data, size_t len) {
71    if (type == WS_EVT_DATA && len == 6 && memcmp(data, "toggle", 6) == 0) {
72      ledAcik = !ledAcik;
73      digitalWrite(LED_PIN, ledAcik ? HIGH : LOW);
74      sunucu->textAll(ledAcik ? "LED: ON" : "LED: OFF");
75    }
76  });
77
78  server.addHandler(&ws);
79  server.begin();
80}
81
82void loop() {
83  ws.cleanupClients();               // saniyede bir yeterli / once per second is enough
84  delay(1000);
85}
This protection is a good enough lock for a home network, but it isn't encryption: ESPAsyncWebServer doesn't provide HTTPS on the ESP32, and WebSocket messages travel in plain text. Since the password sits in the code, remember to remove it before sharing your code. Don't open the panel to the internet with port forwarding on your router; for remote control, an ESP32 Telegram bot, MQTT and Home Assistant or your router's VPN feature are much safer. In newer library versions you can do the same job with the AsyncAuthenticationMiddleware class.

Notes for the ESP8266 and Raspberry Pi Pico W

The same library also runs on the ESP8266 (NodeMCU, Wemos D1 mini). Here's what to change: install ESP32Async's ESP Async TCP library instead of Async TCP; include ESP8266WiFi.h instead of WiFi.h, ESPAsyncTCP.h instead of AsyncTCP.h and ESP8266mDNS.h instead of ESPmDNS.h, and add the line MDNS.update() inside loop(). Replace WiFi.setSleep(false) with WiFi.setSleepMode(WIFI_NONE_SLEEP) and set the pins according to the NodeMCU column of the wiring table. The ESP8266 has only about 80 KB of usable RAM, so keep the page small and remember that the client limit is 4. If you're using a Raspberry Pi Pico W, you'll find how to build a web server with MicroPython in the Raspberry Pi Pico W Wi-Fi guide; newer versions of ESPAsyncWebServer also support the RP2040 and RP2350 chips on the Arduino side.

ESP32 web server project ideas

  • Multi-channel smart socket: use a 4-channel relay board to make separate buttons for a lamp, a fan and a socket; tell the commands apart with short texts such as "r1" and "r2". For choosing a relay, see the relay module article.
  • Plant watering panel: show the soil moisture sensor value live, set the watering threshold with a slider on the page and run the pump by hand when you want.
  • Weather station: use a BME280 instead of the DHT22 to add air pressure, and show the last 60 readings in a small line chart on the page.
  • Colour-picker lamp: send the value of a colour picker on the page over WebSocket and paint a WS2812B NeoPixel strip instantly.
  • Servo on a slider: send the slider's value on every movement and turn a servo motor; ideal for aiming a camera or a door latch.
  • Time-stamped logging: add the time fetched over NTP to each reading, and update your code wirelessly over OTA instead of over USB every time.

Common errors and how to fix them

SymptomCauseFix
'mbedtls_md5_starts_ret' was not declared in this scopeOld me-no-dev library with ESP32 core 3.xInstall ESP32Async's ESP Async WebServer and Async TCP, delete the old folders
Multiple libraries were found for "AsyncTCP.h"Two copies of the same libraryDelete the old -master copies in the libraries folder
Deprecated warning for send_Psend_P is being removed in the new versionUse request->send(200, "text/html", index_html)
The page doesn't open at allPhone on a guest network or mobile data, or the IP address changedJoin the same Wi-Fi and use the current IP from the Serial Monitor
The page opens but the values stay at --WebSocket couldn't connect: different path or addHandler forgottenOpen the F12 console and match ws("/ws") with the address in the JavaScript
Task watchdog got triggered with async_tcpdelay(), a long loop or an HTTP request inside a callbackKeep the callback short and move the work to loop() with a flag
Too many messages queuedtextAll() is called too oftenSend only on change, or a few times per second
The LittleFS page returns 404data folder not uploaded or setDefaultFile missingUpload the files with the plugin and add setDefaultFile("index.html")
does not exist, no permits for creation in the Serial MonitorThe requested file isn't in LittleFSCheck the file name and its upper and lower case; lines mentioning .gz are harmless
The relay never switches offThe 5 V module doesn't recognise a 3.3 V HIGHUse a 3.3 V compatible module, JD-VCC or a transistor
The ESP32 resets when the relay clicksThe coil current pulls the supply down (brownout)Use a solid 5 V adapter and a 470 µF capacitor
esp32.local doesn't openThe device has no mDNS support or the name was written with .localWrite MDNS.begin("esp32") and use the IP address if needed
The button reacts lateWi-Fi modem sleep is onCall WiFi.setSleep(false) after connecting
When something doesn't work, open the developer tools in your browser with F12. The Console tab shows errors such as WebSocket connection to 'ws://192.168.1.50/ws' failed; in the Network tab, click the WebSocket connection and the Messages section shows every message going in and out. On the ESP32 side, printing clients as they connect and leave to the Serial Monitor tells you within seconds whether the problem is in the browser or on the board. If the board restarts the moment the relay clicks, see the ESP32 brownout guide.

The combination of an ESP32 async web server and WebSocket is the shortest route to a fast control panel that works without a cloud service or writing a phone app, and that several devices can share. Start with the single-file PROGMEM panel, move to LittleFS as the interface grows, and give each new device on your network its own mDNS name. If you're new to the ESP32, you'll find the basics in What Is the ESP32? In the BlueGrays app you can also explore ESP32 projects such as an async web server, a WebSocket counter, mDNS access, an NTP clock and OTA updates step by step, with circuit diagrams and ready-to-use code.

Frequently asked questions

What is the difference between ESPAsyncWebServer and WebServer on the ESP32?

The WebServer library handles requests one by one through the handleClient() call in loop(); if loop() slows down, so does the page. ESPAsyncWebServer handles requests in a separate background task, copes with several connections at once and comes with WebSocket and SSE support built in. For real-time panels, the asynchronous version is the better fit.

How many clients can connect to an ESP32 web server at the same time?

By default ESPAsyncWebServer allows 8 WebSocket clients on the ESP32 and closes the extra ones when cleanupClients() is called; on the ESP8266 the limit is 4. Because normal page requests are short, more devices can open the page one after another. In practice, 3–5 simultaneous devices run smoothly on a home panel.

Can I access my ESP32 web server from the internet?

It's technically possible with port forwarding on your router, but it isn't recommended: the library doesn't provide HTTPS on the ESP32, and the device stays exposed to attacks from the internet. For remote control, a Telegram bot, an MQTT-based system or your router's VPN feature is much safer.

Why is esp32.local not working?

The most common reason is that the phone or computer can't resolve mDNS names; this happens especially on some Android phones and older Windows versions. Writing MDNS.begin("esp32.local") in the code is also a mistake, because you should give the name without .local. If the name still doesn't resolve, use the IP address from the Serial Monitor or reserve a fixed IP for the ESP32 on your router.

Can an ESP32 web server work without a router?

Yes. With WiFi.softAP("ESP32-Panel", "12345678") the ESP32 creates its own Wi-Fi network; connect your phone to it and open 192.168.4.1 in the browser. There's no internet in this access point mode, but the panel and WebSocket work just the same. The network password must be at least 8 characters long.

Which ESPAsyncWebServer library should I install?

In the Arduino Library Manager, install the ESP Async WebServer and Async TCP libraries whose author is ESP32Async. The original version on the me-no-dev account is no longer maintained and doesn't compile with ESP32 core 3.x. Also delete any old copies added from a ZIP from your libraries folder.

See all projects
ESP32 Asynchronous Web Server (with IP Display on LCD)
#129Advanced

ESP32 Asynchronous Web Server (with IP Display on LCD)

In this project, we’re turning the ESP32 into an asynchronous web server that you can control from your phone or computer on the same Wi-Fi network. Thanks to the ESPAsyncWebServer library, the server can process multiple requests simultaneously (unlike the classic WebServer.h, which blocks on a single request). You’ll be able to open the ESP32’s IP address in a browser and click the ON/OFF buttons (the /on and /off routes) to turn the LED connected to GPIO 5 on and off.

4.8(16)
26
ESP32 WebSocket Counter (2 Buttons + LCD + Real-Time Web)
#130Advanced
Premium

ESP32 WebSocket Counter (2 Buttons + LCD + Real-Time Web)

In this project, two buttons wired to the ESP32 (GPIO 5 counts up, GPIO 4 counts down) change a counter. The value updates instantly both on a 16x2 I2C LCD and, through the WebSocket support of ESPAsyncWebServer, in every connected browser without reloading the page. Instead of HTTP polling a single persistent connection is used, and the ESP32 sends the new value to everyone with ws.textAll().

4.5(14)
23
ESP32 mDNS: Accessing the Device via bluegrays-esp.local (with LCD Screen)
#131Advanced
Premium

ESP32 mDNS: Accessing the Device via bluegrays-esp.local (with LCD Screen)

In this project, we add an mDNS (Multicast DNS) service to the ESP32, allowing us to access the device using a human-readable hostname like bluegrays-esp.local instead of an IP address. Even if the IP address assigned by DHCP changes, you can always reach the device using the same name. We also run a small web server on the device to send LED control commands via a browser using the address http://bluegrays-esp.local/. The I2C LCD screen displays the hostname, IP address, and LED status in b

4.8(14)
23
ESP32 Wi-Fi STA Connection (with LCD Screen)
#127Advanced

ESP32 Wi-Fi STA Connection (with LCD Screen)

In this project, we connect the ESP32 to the home Wi-Fi network in Station (STA) mode in a secure, fault-tolerant manner. We store the SSID and password in a separate secrets.h file to prevent leaking to Git, set up an auto-reconnect mechanism, and display the RSSI signal level and IP address on an I2C LCD screen. The internal LED works as a status indicator. It is a production-grade connection manager.

4.4(21)
34
ESP32 DHT22 Temperature and Humidity (with LCD Display)
#117Intermediate

ESP32 DHT22 Temperature and Humidity (with LCD Display)

This project measures ambient temperature and humidity using a DHT22 sensor and displays the data in real time on a 16x2 I2C LCD. Data is read from the sensor using the one-wire protocol via the Adafruit DHT library. This project serves as the foundation for IoT weather stations, smart home automation, or environmental monitoring systems in greenhouses. Communication with the DHT22 is established via the ESP32's GPIO 4 pin.

4.8(38)
56
ESP32 Smart Lighting (PIR + LDR + Relay + OLED)
#141Advanced
Premium

ESP32 Smart Lighting (PIR + LDR + Relay + OLED)

Turn your hallway, bathroom, or pantry into smart lighting with ESP32! Using a PIR motion sensor, LDR light sensor, and a relay module, we build the classic "light turns on when motion is detected in the dark" scenario — if the environment is already bright, motion does NOT trigger the light, saving energy during the day. The light stays on for 30 seconds; if motion continues, the timer resets and the light stays on longer. The SSD1306 OLED shows large-font status (ON / DAYTIME / WAITING), a cou

4.3(6)
11
ESP32 OTA Update — Wireless Firmware Installation (With LCD Screen)
#132Advanced
Premium

ESP32 OTA Update — Wireless Firmware Installation (With LCD Screen)

In this project, you’ll learn how to upload new firmware to the ESP32 over Wi-Fi without a USB cable. Using the ArduinoOTA library, the ESP32 becomes visible on the network and is automatically listed as bluegrays-ota in the "Network port" tab of the Arduino IDE. The initial upload is done via USB, and all subsequent updates are wireless—a critical feature for field devices. The I2C LCD screen displays the Wi-Fi status, IP address, hostname, OTA upload percentage, and error messages in both

4.6(14)
22
Real-Time Clock from the Internet via ESP32 NTP
#128Advanced
Premium

Real-Time Clock from the Internet via ESP32 NTP

The ESP32 connects to Wi-Fi and retrieves real time with millisecond precision from NTP (Network Time Protocol) servers. Thanks to the configTzTime() function, the Turkey time zone (TRT, UTC+3) is automatically applied, and DST transitions in countries that observe daylight saving time are automatically managed using a POSIX TZ string. The time is displayed both on the Serial Monitor and on an optional I2C LCD. configTzTime() starts the SNTP service; periodic resynchronization takes place wh

4.5(13)
21