esp32 advanced 45 min

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.

Code available for: ESP32 ArduinoArduino C
Published Aug 26, 2026

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 a WiFiClientSecure and the same setCACert() 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 in loop(). Check the method matches (GET vs POST).
  • “CORS error in browser console.” You did not send the Access-Control-Allow-Origin header, 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() and server.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. ESPAsyncWebServer has built-in websocket handlers.
  • Authentication via bearer tokens in the Authorization header. 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.