Repository navigation
v3.12.x: monotonic ~450B heap decline per HTTP request (not seen on v3.6.0) #482
Description
Activity
Can you please provide a minimal reproducing example, or demonstrate a replication with one of the example sketches from this repo? (
PerfTests.inois our usual go-to for tracing potential leaks.)Reproduced this independently on a Seeed XIAO ESP32-C5 (RISC-V, same family as the ESP32-C3 reported above), and have data that narrows it down further — it's not specific to streaming/chunked responses, and it's not fixed by the version this issue title implies is safe.
Setup: pioarduino
platform-espressif32(Arduino core 3.x),AsyncTCP@3.5.0pinned throughout all tests below, onlyESPAsyncWebServerversion varied.What I tried, all on the same hardware/build:
- A hand-rolled
beginChunkedResponse()with astd::functioncallback streaming a ~53KBStringin small pieces — leaks. - The library's own simplest path,
request->beginResponse(200, "text/html", myString)for that same ~53KBString— leaks identically. No custom callback, noshared_ptr, nothing app-side to account for it. - Downgraded
ESPAsyncWebServerfrom3.12.1to3.9.6(pre-dating the v3.10–v3.12 refactor this issue blames) withAsyncTCPheld at3.5.0— leaks identically. Same per-request decline, same eventual death.
Small, single-TCP-segment responses (a ~40–70 byte JSON endpoint on the same server) show zero leak over dozens of repeated requests — heap is flat. Only responses that need multiple packets (i.e. almost anything over ~1.4KB) leak.
Given (2) and (3), the common factor across every leaking case is that they all go through the same underlying multi-packet send path — and
AsyncTCP(notESPAsyncWebServer) is the one dependency I never varied. That points atAsyncTCP's multi-packet/multi-ACK write path rather than the ESPAsyncWebServer response/request refactor.Concrete numbers (heap_free reported via
ESP.getFreeHeap(), requests spaced ~0.5–1s apart, PSRAM enabled and confirmed healthy — the ~53KB page itself builds fine in PSRAM every time):baseline: 23828 request 1: 17004 (-6824) request 2: 10436 (-6568) request 3: 6272 (-4164) request 4: 4340 (-1932) request 5: 3960 (-380) request 6: 3540 (-420) request 7+: device stops responding entirely (softAP no longer answers ARP)No recovery between requests, no crash/panic/reboot in the serial log — the device just goes silent at the network layer once internal heap is exhausted.
Happy to provide the full minimal sketch / build config if useful — this is on a real board, not a simulator, and 100% reproducible on demand.
- A hand-rolled
@lateINc Yes, an example sketch that demonstrates the problem would be very helpful.
@willmmiles Here's a minimal sketch — single file, no PSRAM, no other libraries, just
AsyncTCP+ESPAsyncWebServer. Two endpoints:/big(fixed-length response, content generated on the fly via callback so there's no app-side buffer at all) and/small(tiny single-packet JSON, for comparison).// leak_repro.ino #include <WiFi.h> #include <AsyncTCP.h> #include <ESPAsyncWebServer.h> static const char *AP_SSID = "leaktest"; static const char *AP_PASS = "leaktest123"; static const size_t BIG_LEN = 53000; // ~53KB: needs many TCP packets over WiFi (MSS ~1460B) AsyncWebServer server(80); void setup() { Serial.begin(115200); delay(200); WiFi.mode(WIFI_AP); WiFi.setSleep(false); WiFi.softAP(AP_SSID, AP_PASS); Serial.printf("AP up: %s ip=%s\n", AP_SSID, WiFi.softAPIP().toString().c_str()); // Fixed-length response filled via callback -- no big buffer anywhere on // the app side, so any leak here is not app-code allocation. server.on("/big", HTTP_GET, [](AsyncWebServerRequest *request) { AsyncWebServerResponse *response = request->beginResponse( "text/plain", BIG_LEN, [](uint8_t *buffer, size_t maxLen, size_t index) -> size_t { size_t remaining = BIG_LEN - index; size_t chunk = remaining < maxLen ? remaining : maxLen; memset(buffer, 'A' + (index % 26), chunk); // deterministic filler return chunk; }); request->send(response); }); server.on("/small", HTTP_GET, [](AsyncWebServerRequest *request) { char buf[64]; snprintf(buf, sizeof(buf), "{\"heap_free\":%u}", (unsigned)ESP.getFreeHeap()); request->send(200, "application/json", buf); }); server.begin(); } void loop() { static unsigned long last = 0; if (millis() - last > 2000) { last = millis(); Serial.printf("[loop] heap_free=%u\n", (unsigned)ESP.getFreeHeap()); } }
; platformio.ini [env:seeed_xiao_esp32c5] platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip board = seeed_xiao_esp32c5 framework = arduino monitor_speed = 115200 lib_deps = esp32async/AsyncTCP@^3.5.0 esp32async/ESPAsyncWebServer@^3.12.1
Result on real hardware (
curl /bigrepeatedly,curl /smallafter each to read heap without adding its own leak):baseline: 162528 try1: 162060 (-468) try2: 161796 (-264) try3: 161528 (-268) try4: 161272 (-256) try5: 161036 (-236) try6: 160788 (-248) try7: 160512 (-276) try8: 160276 (-236)And
/smallalone, 8x in a row: flat at160276every time, zero decline.No PSRAM involved this time (deliberately, to rule it out) — plain internal SRAM only. Same ~250-470B/request decline this issue's title describes, no recovery, confirmed on a Seeed XIAO ESP32-C5. Let me know if you'd like this on a different chip too — I don't have an S3/C3 on hand right now but can try to get one.
@lateINc I don't have a C5 on my test bench right now, but this sketch did not reproduce a leak in my tests with a C3. I did see some RAM consumed after each connection completed, but it was fully released after the 120 second TIME_WAIT expired.
@willmmiles You were right, and I was wrong — apologies for the noise. I added constructor/destructor instrumentation to
AsyncWebServerRequestandAsyncAbstractResponseand can now show exactly what's happening:+AsyncWebServerRequest 0x40837a2c heap_free=162636 +AsyncAbstractResponse 0x40837b88 heap_free=162292 ~AsyncWebServerRequest 0x40837a2c heap_free=150648 ~AsyncAbstractResponse 0x40837b88 heap_free=150864Every request/response object is constructed and destructed correctly, every single time — there is no C++-level object leak anywhere in this library. But heap is still lower after both destructors run than before the objects existed. That's exactly consistent with your explanation: our server sends
Connection: closeon every response, so each/bigrequest tears down and rebuilds a full TCP connection, and the closed socket sits in TIME_WAIT holding lwIP resources until the timer expires.I re-ran the original repro, then waited ~140s with zero traffic afterward instead of hammering it — heap went from a degraded/unresponsive state back to within a few hundred bytes of baseline (162680 → 162460) both times I tested it. So: not a leak, just TIME_WAIT accumulation from rapid non-keepalive connection churn on a memory-constrained target, exactly as you described.
Sorry for the false alarm — feel free to close this (or let me know if the ~450B/request pattern in the original report from @macdylan is a separate thing worth keeping open for). Thanks for taking the time to look at it.
I'm late to the party but this was my suspicion from start also. A soon asynctcp starts receiving or sending requests lwip is allocating on heap and even after the request flow completes, it takes a while for the memory to be free (1 minute or so), snd can happen in 2 times. In our exemples we display the free heap in the loop and you can easily see this behaviour.
I will close this issue.
Environment
beginResponse(contentType, contentLength, AwsResponseFiller)callback streaming (PROGMEM bodies, ETag/304)Observation
Six sequential full-body 200 fetches (one at a time, ~1s apart), free heap read via
/aboutafter each:A monotonic ~400-500B decline per request that does not recover within minutes. On v3.6.0 the same app was verified flat across 40 sequential fetches, so this looks specific to the v3.10-v3.12 response/request refactor (shared_ptr / std::function chains, new response object model).
On a heap-tight device (~42KB total) this compounds quickly: within ~60 requests the largest free block drops below our serve threshold and every large asset answer turns into 503s.
Extra data point
During the same bench session one spontaneous reboot occurred (RTC_SW_CPU_RST) with no panic captured — no stack available, mentioned only in case it rings a bell alongside the memory issue.
Happy to run any instrumented build or provide our bench scripts (settle >130s methodology rules out lwip TIME_WAIT retention — the decline persists well inside the TIME_WAIT window and monotonically).