ESP32: HTTP server in depth, routing, JSON APIs, and async handlers
Build a real HTTP API on the ESP32. Route by path, handle GET vs POST, parse JSON bodies, serve JSON responses, and use async handlers when the request rate goes up.
The first ESP32 HTTP server I wrote was a single page that
returned “Hello, world!” and a list of GPIO states. It worked.
Then I tried to add a second page. Then a third. Then someone
asked me to add a POST endpoint for setting the GPIO state from
a browser. Then I needed CORS because the dashboard lived on a
different host. By the time I had eight endpoints, four HTTP
methods, JSON parsing on the way in, JSON serialization on the
way out, and proper error handling, the original “Hello, world!”
was buried under a pile of if (uri == "/foo") branches that I
was afraid to touch.
This tutorial is what I wish I had read first. It covers the
routing pattern (on("/path", handler)), the HTTP method
dispatch, JSON request and response handling, the async handler
library that lets you serve many concurrent requests without
blocking, the CORS pattern for browser clients, and the “request
was too big” gotcha that bites when someone POSTs a 100 KB
payload to a sensor endpoint.
WebServer vs WebServerSecure (the first decision)
The Arduino IDE ships two libraries for HTTP serving:
WebServer(HTTP, plain text). Default port 80. Used for LAN-only servers where TLS is overkill.WebServerSecure(HTTPS). Default port 443. Used when the client is on a different network and you want the bytes encrypted on the wire. Requires aWiFiClientSecureand the samesetCACert()pattern from the TLS tutorial.
For a home dashboard on the same WiFi, plain HTTP is fine. The traffic is on a network you control. Adding HTTPS adds the cert management burden for no real security gain (the attacker is already on your WiFi if they can sniff the traffic).
For anything exposed to the internet (port-forwarded, on a public WiFi, behind a reverse proxy that terminates TLS), use HTTPS. The TLS tutorial covers the CA bundle part.
Route patterns (the on("/path", handler) API)
WebServer (and WebServerSecure) expose a clean routing API:
#include <WiFi.h>
#include <WebServer.h>
WebServer server(80);
void handleRoot() {
server.send(200, "text/plain", "Hello, world!");
}
void handleStatus() {
server.send(200, "text/plain", "OK");
}
void handleNotFound() {
server.send(404, "text/plain", "Not found");
}
void setup() {
Serial.begin(115200);
WiFi.begin("ssid", "password");
while (WiFi.status() != WL_CONNECTED) delay(500);
server.on("/", handleRoot);
server.on("/status", handleStatus);
server.onNotFound(handleNotFound);
server.begin();
Serial.print("Listening on ");
Serial.println(WiFi.localIP());
}
void loop() {
server.handleClient();
}
Three pieces:
server.on(path, handler): register a handler for a specific path. The handler is called when the request matches.server.onNotFound(handler): register the fallback handler. Called for any path that did not match a registered route.server.handleClient(): the per-loop call that processes incoming requests. Must be called regularly or the server stalls.
Routes are exact-match. /status does not match
/status/details. If you want tree-style routing, register each
path explicitly or check server.uri() inside the handler.
HTTP method handling (GET vs POST)
The default server.on(path, handler) matches any HTTP method.
To dispatch by method:
server.on("/sensor", HTTP_GET, []() {
server.send(200, "application/json", "{\"temp\":22.5}");
});
server.on("/sensor", HTTP_POST, []() {
// Read the POST body
if (!server.hasArg("plain")) {
server.send(400, "application/json", "{\"error\":\"no body\"}");
return;
}
String body = server.arg("plain");
// Parse body, update sensor...
server.send(200, "application/json", "{\"ok\":true}");
});
The HTTP method constants are HTTP_GET, HTTP_POST,
HTTP_PUT, HTTP_DELETE, HTTP_PATCH, HTTP_HEAD,
HTTP_OPTIONS. Registering the same path with different methods
gives you proper REST-style routing.
HTTP_OPTIONS is special: it is what browsers send as a CORS
preflight request. If your API is called from a browser on a
different origin, you need to handle OPTIONS. More on that below.
Reading query parameters
Query strings (the ?foo=bar&baz=qux part of the URL) come in
as named arguments:
server.on("/set", HTTP_GET, []() {
if (!server.hasArg("value")) {
server.send(400, "text/plain", "missing value");
return;
}
String value = server.arg("value");
// Use value...
server.send(200, "text/plain", "set to " + value);
});
Call the URL as /set?value=42. The library parses the query
string and exposes each parameter as a named argument.
Parsing JSON request bodies
For POST requests, the body comes in as the “plain” argument:
#include <ArduinoJson.h>
server.on("/config", HTTP_POST, []() {
if (!server.hasArg("plain")) {
server.send(400, "application/json", "{\"error\":\"no body\"}");
return;
}
StaticJsonDocument<512> doc;
DeserializationError err = deserializeJson(doc, server.arg("plain"));
if (err) {
server.send(400, "application/json", "{\"error\":\"bad json\"}");
return;
}
const char* ssid = doc["ssid"] | "default-ssid";
const char* pass = doc["pass"] | "default-pass";
// Apply config...
WiFi.begin(ssid, pass);
server.send(200, "application/json", "{\"ok\":true}");
});
ArduinoJson is the standard. Install via Sketch >> Include Library >> Manage Libraries >> search ArduinoJson. The
StaticJsonDocument<512> allocates 512 bytes on the stack. For
larger payloads use DynamicJsonDocument or bump the static
size.
Serving JSON responses with proper Content-Type
Always set Content-Type: application/json on JSON responses.
The browser and curl both use this header to decide how to
render the response:
StaticJsonDocument<256> doc;
doc["temperature"] = 22.5;
doc["humidity"] = 55.0;
doc["timestamp"] = millis();
String response;
serializeJson(doc, response);
server.send(200, "application/json", response);
serializeJson writes the JSON to a String. For small
payloads this is fine. For large ones, use serializeJson(doc, server.client()) to write directly to the socket and skip the
intermediate string allocation.
404 and 500 handling
Two error paths you have to wire up explicitly:
- 404 Not Found for paths that did not match any route.
Register with
server.onNotFound(). - 500 Internal Server Error for handlers that throw or hit an unexpected state. Wrap handler bodies in try/catch where possible, or set up a generic error responder:
server.onNotFound([]() {
StaticJsonDocument<64> doc;
doc["error"] = "not found";
doc["path"] = server.uri();
String body;
serializeJson(doc, body);
server.send(404, "application/json", body);
});
For the 500 case, put server.send(500, "application/json", ...)
in any catch block or after any if (!ok) check that you cannot
recover from.
The async handler pattern (ESPAsyncWebServer)
The synchronous WebServer blocks the request thread while a
handler runs. For most ESP32 projects that is fine: each handler
takes milliseconds, and the WiFi stack has its own thread. But
when a handler does anything slow (reads a sensor over I2C, waits
for an HTTP call to an upstream service, runs an OTA check), the
entire server stalls for that duration. Other clients get timeouts.
ESPAsyncWebServer is the fix. Handlers return immediately and
the server sends the response when it is ready:
#include <ESPAsyncWebServer.h>
AsyncWebServer server(80);
server.on("/slow", HTTP_GET, [](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
// Start the slow operation in the background
// (e.g. trigger an I2C read, fire an HTTP request, etc.)
request->send(200, "application/json", "{\"started\":true}");
});
Install via the Arduino Library Manager: search ESPAsync WebServer by me-no-dev.
The async version handles concurrent requests cleanly. If you have more than 5-10 simultaneous clients, or any handler that takes more than a few hundred milliseconds, the async version is worth the swap.
The catch: ESPAsyncWebServer does not handle chunked responses
or websockets as cleanly as the sync version, and the request
body API is different. For a pure REST API with small payloads,
the swap is easy. For anything fancier, expect to read the
library source.
CORS for browser-based clients
If your dashboard is served from dashboard.example.com and the
ESP32 API is on 192.168.1.42, the browser blocks the request
unless the ESP32 sends the right CORS headers. The minimum:
server.on("/sensor", HTTP_GET, [](AsyncWebServerRequest *request) {
AsyncWebServerResponse *response = request->beginResponse(
200, "application/json", "{\"temp\":22.5}");
response->addHeader("Access-Control-Allow-Origin", "*");
// For credentialed requests, replace * with the specific origin
// and add: Access-Control-Allow-Credentials: true
request->send(response);
});
For POST and other methods, you also need to handle the
preflight:
server.on("/sensor", HTTP_OPTIONS, [](AsyncWebServerRequest *request) {
AsyncWebServerResponse *response = request->beginResponse(204);
response->addHeader("Access-Control-Allow-Origin", "*");
response->addHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
response->addHeader("Access-Control-Allow-Headers", "Content-Type");
request->send(response);
});
The * for Access-Control-Allow-Origin is fine for
development. For production, lock it down to the specific
origin(s) you serve the dashboard from.
The “request was too big” gotcha
WebServer has a default max request size of about 1 KB (or
whatever _maxHeadersLength is set to in the library). If a
client POSTs a 10 KB JSON body, the body gets truncated or the
request is rejected outright.
The fix: bump the limit. In the sync WebServer:
server.setMaxPayloadLength(16384); // 16 KB
In the async ESPAsyncWebServer:
server.setMaxPayloadLength(16384);
Pick a number that is just larger than your largest expected payload. Going too big (e.g. 1 MB) lets a malicious client tie up RAM with a single request.
For multipart uploads (file uploads), the library has separate
UploadHandler callbacks. Use those instead of trying to handle
file uploads through the body parser.
When to use HTTP server vs MQTT
Two main choices for serving data from the ESP32:
- HTTP server: the client pulls data when it wants it.
Good for “fetch the current sensor reading,” “send me the
device status,” “update this config.” Request-response
pattern, easy to debug with
curl, no broker needed. - MQTT publisher: the ESP32 pushes data when it changes. Good for regular telemetry, multiple subscribers, fanout to dashboards and automation engines.
Most projects use both. The ESP32 publishes telemetry over
MQTT; the dashboard fetches config or sends commands via HTTP.
The HTTP server tutorial (esp32-http-server-in-depth) is the
second half; the MQTT tutorial (esp32-mqtt-publish-subscribe)
is the first.
When something breaks
- “Handler not called.” Check the route registration. Check
that
server.handleClient()is inloop(). Check the method matches (GET vs POST). - “CORS error in browser console.” You did not send the
Access-Control-Allow-Originheader, or you did not handle the OPTIONS preflight. - “Request times out.” A handler is blocking. Either split
the handler into a quick “started” response + background
work, or move to
ESPAsyncWebServer. - “Body is empty when I read it.” The client did not send a
Content-Length header, or the body was too big and got
truncated. Check
server.contentLength()andserver.hasArg("plain"). - “Server stops responding after a few hours.” Memory leak
in a handler, or the async server has a stuck request. Watch
ESP.getFreeHeap()over time. If it is dropping, you are leaking.
What to build next
- A real REST API:
/sensor(GET returns JSON),/sensor(POST updates the reading),/config(GET/POST for WiFi credentials),/reset(POST triggers a restart). - A websocket endpoint that pushes sensor readings every second
to connected clients.
ESPAsyncWebServerhas built-in websocket handlers. - Authentication via bearer tokens in the
Authorizationheader. Without it, anyone on your WiFi can call your API. - An MQTT-based equivalent (
esp32-mqtt-publish-subscribe) and a comparison of which you reach for first.