Skip to content

v3.12.x: monotonic ~450B heap decline per HTTP request (not seen on v3.6.0) #482

Description

@macdylan

Environment

  • ESP32-C3 (arduino-esp32 3.2.1 / IDF 5.4), ESPAsyncWebServer v3.12.1 + AsyncTCP 3.5.0
  • Responses served via beginResponse(contentType, contentLength, AwsResponseFiller) callback streaming (PROGMEM bodies, ETag/304)
  • Idle free heap identical to v3.6.0 (~41.7KB, largest block ~22.5KB) — the difference appears under request load

Observation

Six sequential full-body 200 fetches (one at a time, ~1s apart), free heap read via /about after each:

37020 -> 36804 -> 36344 -> 35876 -> 35648 -> 35172 -> 34744

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).

Activity

  1. willmmiles commented on Sep 19, 2026

    @willmmiles

    Can you please provide a minimal reproducing example, or demonstrate a replication with one of the example sketches from this repo? (PerfTests.ino is our usual go-to for tracing potential leaks.)

  2. lateINc commented on Sep 20, 2026

    @lateINc

    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.0 pinned throughout all tests below, only ESPAsyncWebServer version varied.

    What I tried, all on the same hardware/build:

    1. A hand-rolled beginChunkedResponse() with a std::function callback streaming a ~53KB String in small pieces — leaks.
    2. The library's own simplest path, request->beginResponse(200, "text/html", myString) for that same ~53KB String — leaks identically. No custom callback, no shared_ptr, nothing app-side to account for it.
    3. Downgraded ESPAsyncWebServer from 3.12.1 to 3.9.6 (pre-dating the v3.10–v3.12 refactor this issue blames) with AsyncTCP held at 3.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 (not ESPAsyncWebServer) is the one dependency I never varied. That points at AsyncTCP'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.

  3. willmmiles commented on Sep 20, 2026

    @willmmiles

    @lateINc Yes, an example sketch that demonstrates the problem would be very helpful.

  4. lateINc commented on Sep 20, 2026

    @lateINc

    @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 /big repeatedly, curl /small after 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 /small alone, 8x in a row: flat at 160276 every 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.

  5. willmmiles commented on Sep 20, 2026

    @willmmiles

    @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.

  6. lateINc commented on Sep 20, 2026

    @lateINc

    @willmmiles You were right, and I was wrong — apologies for the noise. I added constructor/destructor instrumentation to AsyncWebServerRequest and AsyncAbstractResponse and 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=150864
    

    Every 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: close on every response, so each /big request 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.

  7. willmmiles commented on Sep 20, 2026

    @willmmiles

    @lateINc No worries. TIME_WAIT is a common confounder for leak testing with this project. I'll leave this issue entry open for @macdylan in the hopes they've got a reproducer.

  8. mathieucarbou commented on Oct 7, 2026

    @mathieucarbou
    Member

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions