Async web server
A page served by the board itself, so a phone on the same Wi-Fi can see your readings with no app and no cloud. It is the most satisfying thing this chip does, and it goes wrong the moment a second person opens it.
One tab hides the problem
The shape that works
- HTML in flash, data over JSON. Put
index.htmlon LittleFS and serve it statically. The page then fetches/api/nowevery second and updates itself. Building HTML strings in C++ is the thing people do first and regret. - Handlers never block. They read a variable, they answer, they return.
loop()keeps that variable fresh. - Under 50 ms per handler. Past that, a phone with four connections open starts to feel broken.
When to reach for WebSockets instead
Polling every second is fine for a temperature. For anything that changes fast,
or where the board needs to push — a button state, a live graph, a log — a
WebSocket is one persistent connection instead of a new request per second, and
AsyncWebSocket is part of the same library.
Serving to the internet
Don't forward a port. The board has no certificate, no authentication and a TCP stack that is small on purpose. Push to an MQTT broker, or use a tunnel, and keep the web server for the local network where it belongs.
The code
Two routes - one HTML page from flash, one JSON endpoint the page polls. Keeping the data separate from the page is what lets the browser update without reloading.
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <LittleFS.h>
AsyncWebServer server(80);
float temperature = 0; // kept fresh by loop(), read by handlers
void setup() {
Serial.begin(115200);
LittleFS.begin(true);
WiFi.begin("your-network", "your-password");
while (WiFi.status() != WL_CONNECTED) delay(250);
Serial.println(WiFi.localIP());
server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
server.on("/api/now", HTTP_GET, [](AsyncWebServerRequest *r) {
r->send(200, "application/json",
"{\"temp\":" + String(temperature, 1) + "}");
});
server.onNotFound([](AsyncWebServerRequest *r) {
r->send(404, "text/plain", "no such page");
});
server.begin();
}
void loop() {
static unsigned long last = 0;
if (millis() - last >= 1000) {
last += 1000;
temperature = 20 + (millis() % 5000) / 1000.0; // your sensor here
}
}Handlers are callbacks and they must not block. No delay, no waiting on a sensor, no long loops. Read a variable that loop() keeps fresh, answer, and return.
MicroPython has no async web server in the standard build, so this uses asyncio directly. It is more code and it makes the shape obvious - one coroutine per connection, nothing blocking.
import asyncio, network
temperature = 21.0
async def handle(reader, writer):
await reader.readline()
while await reader.readline() != b'\r\n':
pass
body = '{"temp":%.1f}' % temperature
writer.write(b'HTTP/1.0 200 OK\r\nContent-Type: application/json\r\n\r\n')
writer.write(body.encode())
await writer.drain()
writer.close()
await writer.wait_closed()
async def main():
await asyncio.start_server(handle, '0.0.0.0', 80)
while True:
await asyncio.sleep(1) # your sensor reads go here
asyncio.run(main())Use asyncio, not a blocking socket accept loop. A blocking server serves exactly one browser and appears to hang for everybody else.
When it does not work
You are using the synchronous WebServer, which handles one request at a time. A browser opens several connections for one page, so it queues against itself too.
A handler is blocking. delay inside an async callback stalls the whole TCP stack. Move slow work into loop and have the handler read the result.
The browser cached the JSON. Add a cache-control header to the API route, or a changing query parameter on the fetch.
That is correct and it is not a bug. The board has a private address. Reaching it from the internet needs a tunnel or a broker - see MQTT rather than opening a port on your router.
The board has spent this chapter answering. The next page turns it round and has it ask.
HTTP from a board →Edit this page — content/esp32/async-web-server.mdx
Discuss this article
Ask about this page. The answer stays here, on the page it belongs to, for whoever hits the same wall next.