Skip to content

TCP Connection Budget ​

FastBee device-side web server is built on ESPAsyncWebServer + AsyncTCP, whose concurrency is limited by the lwIP TCP PCB table. Different chips have different resource capabilities, so connection budgets must be configured differently, otherwise pages may fail to load or SSE push may be lost.

Connection allocation follows these principles:

TCP_TOTAL = SSE (persistent push) + HTTP (page/API requests)
  • SSE: Reserved slots for AsyncEventSource (real-time status push)
  • HTTP: Remaining slots for concurrent page loading and API calls
  • Budget constants: Defined in include/core/ResourceProfile.h
  • SSE limit: MAX_SSE_CLIENTS fixed array in SSERouteHandler.h
  • TCP exhaustion threshold: TCP_CONN_EXHAUSTION_THRESHOLD in WebConfigManager.h controls soft-restart trigger

Note: AsyncTCP v3.4.10 no longer reads CONFIG_ASYNC_TCP_MAX_CONNECTIONS macro; it is a legacy configuration. Actual connection count is determined by lwIP MEMP_NUM_TCP_PCB (default 16) and application-layer thresholds.

Chip Hardware Specifications ​

ChipCoreInternal SRAMPSRAMWiFi
ESP32Dual-core Xtensa 240MHz520 KBNone802.11 b/g/n
ESP32-S3Dual-core Xtensa 240MHz512 KB8 MB802.11 b/g/n
ESP32-C6Single-core RISC-V 160MHz512 KBNone802.11 ax (Wi-Fi 6)
ESP32-C3Single-core RISC-V 160MHz400 KBNone802.11 b/g/n

Memory Budget Calculation ​

lwIP TCP buffer parameters were tuned in the 2026-08 memory optimization (overriding ESP-IDF defaults), differentiated per chip:

ParameterValueDescription
MEMP_NUM_TCP_PCB16lwIP TCP PCB hard limit (all variants, cannot exceed)
TCP_SND_BUF2872 B (classic/C3/C6)
4308 B (S3)
Per-connection send buffer (2×MSS / 3×MSS, was 5744 B)
TCP_WND2872 B (classic/C3/C6)
4308 B (S3)
Per-connection receive window (was 5760 B)
TCP PCB struct~172 BlwIP internal control block
Per-connection total~5.8 KB (no PSRAM)
~8.6 KB (S3)
PCB + SND_BUF + WND

Configured via sdkconfig.defaults (no-PSRAM chips, 2×MSS) / sdkconfig.defaults.esp32s3 (S3 with PSRAM, 3×MSS), injected through board_build.cmake_extra_args in platformio.ini. Saves ~11.5 KB DRAM in a 4-connection scenario. Smaller buffers increase fragmentation of large responses and reduce throughput; validate with API performance regression after changes.

Single-user access scenario analysis (browser single host max 6 concurrent):

StateMQTTSSEHTTPTIME_WAITTotal PCB
Page load peak115-62-49-12
Steady state110-21-23-6
Multi-tab (S3)124-63-510-14

Connection Budget by Chip ​

ChipTCP BudgetSSEHTTPExhaustion ThresholdStatus
ESP32 classic61512✅ Implemented
ESP32-S382614✅ Implemented
ESP32-C661512✅ Implemented
ESP32-C341310✅ Implemented

Exhaustion threshold must be < 16 (lwIP hard limit), triggering recovery before limit, leaving 2-6 slot buffer. Memory-constrained chips (C3) trigger earlier, memory-rich (S3) tolerate more connections.

PSRAM Impact on TCP Connection Capacity ​

ESP32-S3 with PSRAM significantly reduces internal DRAM contention, indirectly supporting more concurrent TCP connections:

ScenarioNo PSRAMWith PSRAM (threshold=512B)
Internal DRAM available~18 KB~22-35 KB (HTTP buffers offloaded to PSRAM)
lwIP TCP PCB spaceTight, easily exhaustedSufficient (each PCB ~172B, must be in internal DRAM)
Concurrent HTTP requests2-3 triggers memory pressure6+ still handled normally

CONFIG_ASYNC_TCP_MAX_CONNECTIONS=14 (S3) is a build flag documentation; AsyncTCP v3.4.10 no longer reads it. Actual connection count constrained by lwIP MEMP_NUM_TCP_PCB=16 and application-layer TCP_TOTAL_BUDGET=8. Raising this flag mainly serves as configuration intent documentation and backward compatibility with older AsyncTCP.

Key Files ​

FilePurpose
include/core/ResourceProfile.hTCP_TOTAL_BUDGET / TCP_SSE_BUDGET / TCP_HTTP_BUDGET budget constants (chip-conditional compilation)
include/network/handlers/SSERouteHandler.hMAX_SSE_CLIENTS fixed array slot count
include/network/WebConfigManager.hTCP_CONN_EXHAUSTION_THRESHOLD soft-restart threshold; CHECK_INTERVAL_MS / TIME_WAIT_PRUNE_* pruning parameters; NO_REQUEST_WATCHDOG_MS no-request watchdog
src/network/WebConfigManager.cppProactive abort TIME_WAIT connections every 30s + threshold-based TIME_WAIT pruning + no-request watchdog + exhaustion detection and auto-recovery
src/network/WebHandlerContext.cppStatic file concurrency limiting: STATIC_MAX_INFLIGHT / STATIC_INFLIGHT_WATCHDOG_MS / small-resource exemption / 503 auto-retry page
web-src/index.htmlFrontend boot chunk serial loader: interval / start delay / backoff retry parameters
platformio.iniCONFIG_ASYNC_TCP_MAX_CONNECTIONS in each [xxx_runtime_flags] (legacy flag, AsyncTCP 3.4.10 no longer reads, S3 raised to 14)

Management Measures ​

TIME_WAIT Connection Periodic Cleanup ​

After ESPAsyncWebServer closes a connection, TCP PCB enters TIME_WAIT state (default 2×MSL ≈ 60s), still occupying slots during this period. WebConfigManager traverses lwIP PCB linked list every 30 seconds, proactively aborting timed-out TIME_WAIT connections:

cpp
// WebConfigManager.cpp - Clean up TIME_WAIT every 30s
if (millis() - lastTimeWaitCleanup > 30000) {
    lastTimeWaitCleanup = millis();
    // abort TIME_WAIT connections to free TCP PCB slots
}

TIME_WAIT Proactive Pruning ​

Web responses force Connection: close short-lived connections. Under dense access each request produces a TIME_WAIT PCB (lwIP keeps it for 2×MSL ≈ 120s), quickly exhausting MEMP_NUM_TCP_PCB=16 and stalling new requests. In addition to the 30s periodic cleanup above, WebConfigManager runs a TCP health check every 2s and prunes immediately when TIME_WAIT exceeds the threshold:

ParameterValueDescription
CHECK_INTERVAL_MS2000msTCP health check interval
TIME_WAIT_PRUNE_THRESHOLD4Start pruning when TIME_WAIT exceeds this
TIME_WAIT_PRUNE_KEEP1TIME_WAIT count kept after pruning

Pruning kills only the oldest TIME_WAIT (same strategy as lwIP tcp_kill_timewait). In this state tcp_abort removes the PCB without sending RST, so there is no side effect on already-closed connections.

No-Request Watchdog and SSE Protection ​

When isRunning=true but no HTTP request has arrived for 120s (NO_REQUEST_WATCHDOG_MS) while residual TCP connections exist, the watchdog reaps stale PCBs on port 80 in place (no riskier soft-restart). Two protection rules prevent false kills:

  • Skip reaping while SSE clients are connected: SSE is a legitimate long-lived connection and does not count as HTTP requests. Long periods without HTTP requests are normal in this state, and aborting would cut the browser's in-flight request chain.
  • Authenticated requests record activity uniformly: many handlers send responses via the chunked/stream paths of HandlerUtils, which do not update lastRequestSeenMs. Therefore requireAuth() calls noteWebRequestActivity() after successful authentication, preventing the watchdog from misjudging "no access".

Rule-query handlers read the rule table while holding a lock on the async_tcp task. They must use bounded waits (MutexGuard with an explicit timeout, e.g. 2000ms) and return 503 on timeout. The default portMAX_DELAY infinite wait is forbidden — an infinite wait while rule execution holds the lock would freeze all HTTP requests.

SSE Connection Limit ​

SSERouteHandler uses fixed array _slots[MAX_SSE_CLIENTS] to track clients. ESP32-S3 sets MAX_SSE_CLIENTS=2 (multi-tab support), other chips set MAX_SSE_CLIENTS=1 (single SSE connection). New connections are rejected when slots are full, preventing SSE from filling TCP PCB and blocking HTTP requests.

Static File Concurrency Limiting ​

A browser's initial navigation bursts ~12 resource requests (HTML document + JS chunks + CSS + images + API), and every response forces Connection: close, so resources can only queue with serial / low concurrency. Historically there were failures in both directions:

  • Too much concurrency: 4-way concurrent streaming of a 128KB CSS exhausted DRAM (largest free block dropped to 220B), operator new failed and abort() sent the device into a crash loop
  • Limit too strict: with the cap set to 1, the first-screen burst only admitted 1 request; the rest were RST/timed-out or rejected with 503 "Static file busy", and rejecting the main document left the browser on a blank JSON screen

The current scheme (WebHandlerContext.cpp) balances the two:

ParameterValueDescription
STATIC_MAX_INFLIGHT2In-flight cap for compressible resources (html/js/css); current resources are all ≤22KB gz, so 2-way concurrency stays safely within the 36KB DRAM gate
STATIC_INFLIGHT_WATCHDOG_MS12000Stuck-counter watchdog: when onDisconnect never fires and the counter leaks, force-reset after 12s to avoid permanent 503
Small-resource exemptionUncompressed resources such as favicon/logobypassInFlightCap = !isCompressible; a few-KB single files do not recreate the historical crash scenario (large-file concurrent streaming), and first-screen essentials are no longer rejected
Slot releaseonDisconnectConnection: close guarantees release on completion/timeout/abort

Rejection responses differ by resource importance to avoid the 503 blank screen:

Request typeRejection responseRecovery
Core entry documents (/www/index.html, /www/setup.html incl. gz)503 + text/html auto-retry page (<meta http-equiv="refresh" content="1">)Browser automatically re-navigates every second until recovery
Other static resources503 + JSON (retryAfter: 1)Frontend loader onerror retries with incremental backoff

Frontend loader (web-src/index.html) timing parameters paired with the server-side limiter:

ParameterCurrentOldDescription
Boot chunk interval30ms80ms5 chunks load serially; the interval adds directly to first-screen time
Start delay20ms100msLoading begins after DOMContentLoaded
Failure retry backoff300/1200/2700ms500/2000/4500ms300×attempt², up to 4 attempts (CHUNK_MAX_RETRY)

Measured after the fix (ESP32-F4R0, login page with ~12 resources): the login form is fully visible in ~2.8s (DOM load ≈ 2763ms), meeting the 3-second goal; when the main document is throttled there is no blank screen — the auto-retry page probes recovery every second.

Modification notes:

  • Before raising STATIC_MAX_INFLIGHT, confirm that the largest gz resource size × concurrency still fits the 36KB DRAM gate; measured 4-way 128KB concurrency caused a crash loop — do not raise it blindly
  • The watchdog value must exceed the legitimate transfer duration of a single file (seconds on weak networks), otherwise valid transfers get killed
  • All core entry rejection paths (2 DRAM gates + 1 concurrency limit) must go through sendStaticBusyRetryPage(); falling back to JSON brings back the 503 blank screen. The constants and behaviors above are locked by test/test_static_concurrency.cpp; run pio test -e native regression after changes
  • The frontend loader timing parameters are also locked by test_static_concurrency.cpp; before slowing the backoff, re-measure the device's actual rejection rate with a concurrency probe script

Modification Notes ​

  • Any threshold modification must ensure TCP_CONN_EXHAUSTION_THRESHOLD < 16 (lwIP PCB hard limit)
  • After modifying static-limit constants (STATIC_MAX_INFLIGHT / STATIC_INFLIGHT_WATCHDOG_MS) or frontend loader timing parameters, update and regress the locked assertions in test/test_static_concurrency.cpp
  • When tuning TIME_WAIT_PRUNE_THRESHOLD / TIME_WAIT_PRUNE_KEEP / CHECK_INTERVAL_MS, also check the watchdog constant regression assertions in test_mqtt_protocol.cpp
  • After modifying the no-request watchdog reap logic, keep the SSE-active skip branch (locked by test_mqtt_protocol.cpp)
  • After modifying TCP_TOTAL_BUDGET in ResourceProfile.h, must also update:
    • MAX_SSE_CLIENTS in SSERouteHandler.h (≤ TCP_SSE_BUDGET)
    • TCP_CONN_EXHAUSTION_THRESHOLD in WebConfigManager.h
    • CONFIG_ASYNC_TCP_MAX_CONNECTIONS in each [xxx_runtime_flags] of platformio.ini
    • Expected values in scripts/validate-build-matrix.js
  • ESP32-C3 has smallest RAM (400KB), TCP should not exceed 4
  • CONFIG_ASYNC_TCP_MAX_CONNECTIONS is deprecated by AsyncTCP 3.4.10, but retained as configuration documentation and backward compatibility

FastBee | fastbee.cn