The onClient method in the ESPAsyncWebServer library is a callback hook that triggers the moment a raw TCP socket connection is established, before any HTTP headers are parsed. When you build an IoT dashboard or smart home hub on an ESP32, relying solely on standard HTTP route handlers leaves you entirely blind to the underlying TCP socket layer. By tapping into onClient, you shift your control from the application layer (HTTP) down to the transport layer (TCP), allowing you to manage raw connections, enforce hard limits, and prevent the dreaded out-of-memory reboots that plague high-traffic embedded servers.

The Core Concept: What onClient Actually Does

In the standard ESPAsyncWebServer workflow, you typically use server.on("/", HTTP_GET, ...) to handle requests. This is an application-layer operation. The server's underlying TCP stack (lwIP) accepts the connection, reads the HTTP headers, parses the method and URI, and only then fires your route handler.

The onClient method intercepts the connection at the transport layer. Think of standard route handlers as waiters taking an order after the customer is seated at a table. The onClient callback is the bouncer at the front door checking IDs before anyone even enters the restaurant. It hands you a pointer to the raw AsyncClient object the exact millisecond the TCP handshake completes, giving you the power to inspect, throttle, or immediately sever the connection before a single byte of HTTP data is processed.

Where You Meet This in Practice

You typically don't need onClient for a simple weather station that sends data to a single phone. You meet this in practice when your ESP32 acts as a central hub facing multiple concurrent users or automated polling scripts.

  • Connection Throttling: Preventing heap exhaustion by dropping connections when the active socket count exceeds the ESP32's safe memory threshold.
  • Captive Portal Interception: Detecting raw TCP probes from iOS or Android devices trying to verify internet connectivity, allowing you to redirect them to your captive portal before they time out.
  • Protocol Multiplexing: Inspecting the first few bytes of a raw TCP stream to determine if the client is speaking HTTP, WebSockets, or a custom binary protocol, and routing the AsyncClient object to the appropriate handler.
  • Keep-Alive Auditing: Forcing closure of zombie sockets left open by mobile browsers that backgrounded their tabs without sending a TCP FIN packet.

The Memory Math: A Worked Numeric Example

To understand why onClient is critical for stability, we have to look at the ESP32's memory architecture. An ESP32-WROOM-32 has 520KB of SRAM. However, the FreeRTOS kernel, the WiFi MAC/PHY baseband, and the lwIP TCP/IP stack consume roughly 250KB to 280KB at boot. This leaves you with a usable heap of around 200KB to 220KB.

According to the Espressif Memory Allocation documentation, every active TCP connection requires a Protocol Control Block (PCB) in the lwIP stack, plus the AsyncClient C++ object wrapper and its internal RX/TX buffers.

Active TCP ClientsApprox. RAM per ClientTotal Socket RAMRemaining Free Heap (from 200KB)
102.2 KB22 KB178 KB (Safe)
302.2 KB66 KB134 KB (Safe)
602.2 KB132 KB68 KB (Warning Zone)
902.2 KB198 KB2 KB (Crash Imminent)

If you allow 90 concurrent connections, your free heap drops to nearly zero. When the web server attempts to allocate memory for an incoming HTTP header and the new operator fails, the ESP32 throws a Guru Meditation Error (typically a LoadProhibited panic or an abort() from the C++ standard library) and reboots.

Real-World Scenario: The Smart Home Hub Crash

The Setup: An ESP32 running a home energy monitoring dashboard, serving a 400KB HTML/JS payload to family members' devices.
The Numbers: 12 devices (phones, tablets, laptops) in the house. Modern browsers using HTTP/1.1 open up to 6 concurrent TCP connections per domain to parallelize asset downloads and maintain keep-alive sockets. 12 devices × 6 sockets = 72 active TCP connections.
The Outcome: The ESP32 reboots randomly every 4 to 6 hours, dropping all historical logging data.
What Went Wrong: The developer only used server.on() routes. When a tablet went to sleep, the browser suspended the tab but kept the TCP sockets open (keep-alive). Over a few hours, the socket count crept up to 85+. Heap fragmentation made it impossible to find a contiguous 4KB block for a new incoming request, triggering an out-of-memory (OOM) panic.

The fix was not to buy an ESP32-S3 with more RAM. The fix was to implement onClient to act as a strict bouncer, severing any new connection if the active client count exceeded 30, and actively timing out idle raw sockets.

Implementation Guide: Enforcing Connection Limits

Here is the exact bench-tested procedure to implement a connection limiter using onClient. This prevents the OOM crash described above.

  1. Define a global counter: Create an integer to track active sockets.
  2. Register the onClient callback: Hook into the server before calling server.begin().
  3. Implement the bouncer logic: Check the counter. If it's too high, call client->stop() immediately.
  4. Hook the disconnect event: Decrement the counter when a client drops.
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>

AsyncWebServer server(80);
volatile int activeClients = 0;
const int MAX_CLIENTS = 25; // Safe limit for standard ESP32 heap

void setup() {
  Serial.begin(115200);
  WiFi.begin("YourSSID", "YourPassword");
  while (WiFi.status() != WL_CONNECTED) { delay(500); }
  
  // 1. The Bouncer: Intercept raw TCP connections
  server.onClient([](void *arg, AsyncClient *client) {
    if (activeClients >= MAX_CLIENTS) {
      Serial.println("[TCP] Max clients reached. Dropping connection.");
      client->stop(); // Sever the socket immediately
      return;
    }
    
    activeClients++;
    Serial.printf("[TCP] Client connected. Active: %d\n", activeClients);
    
    // 2. Track disconnections to decrement the counter
    client->onDisconnect([](void *arg, AsyncClient *c) {
      activeClients--;
      Serial.printf("[TCP] Client disconnected. Active: %d\n", activeClients);
      delete c; // Free the AsyncClient object memory
    }, NULL);
    
  }, NULL);

  // Standard HTTP routes go here
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(200, "text/plain", "Dashboard OK");
  });

  server.begin();
}

void loop() {
  // Async server runs in the background via FreeRTOS tasks
}

Common Confusions and FAQ

What do people commonly confuse onClient with?

Developers frequently confuse onClient with server.on() and server.onEvent(). server.on() is strictly for HTTP routing (e.g., matching a URI path like /api/data). server.onEvent() is used for Server-Sent Events (SSE) or WebSockets to push data to already-connected clients. onClient operates below both of these, dealing purely with the raw TCP socket lifecycle.

Does onClient work with HTTPS (TLS)?

If you are using an HTTPS wrapper (like AsyncHTTPSWebServer), the onClient callback fires before the TLS handshake begins. This is highly advantageous because TLS handshakes are incredibly memory-intensive (often requiring 10KB+ of heap per connection). By dropping unauthorized or excess clients at the raw TCP level via onClient, you save the ESP32 from wasting CPU cycles and RAM on doomed TLS negotiations.

Will dropping clients in onClient break browser caching?

No. Browsers are designed to handle dropped TCP connections gracefully. If you call client->stop() in the onClient handler, the browser simply registers a network error for that specific socket and will typically retry the request on a fresh socket or fall back to a cached version of the page. It is much better to drop a socket at the door than to let the entire ESP32 crash and take down the network for all users.