ESP32 Memory Fragmentation: Why Your Device Crashes After Running for Days

ESP32 heap memory fragmentation causing device crashes during long-running operation

Your heap reports 90 KB free. Your 8 KB allocation fails. Your device reboots. You check the logs, scratch your head, and Google “esp32 out of memory,” but you don’t have a memory problem. At least, not the kind you think.

You’ve already checked for leaks. Free heap isn’t decreasing. It’s hovering right where it should be. And yet, every two or three days, something allocates a buffer and gets NULL back. The watchdog fires. The device resets. Your customers notice.

This is ESP32 heap fragmentation, and it’s one of the most misunderstood stability problems in long-running embedded projects. The free memory is there. It’s just been shredded into pieces too small to use.

The good news: once you see it clearly, you can fix it. Let’s get into how.

Fragmentation and Leaks Are Completely Different Problems

This distinction matters because the fixes are completely different. Conflating them, as most “ESP32 out of memory” blog posts do, sends you down the wrong debugging path for days.

A leak means memory is allocated and never freed. Free heap steadily decreases over time until it hits zero. The fix: find the missing free() call.

Fragmentation means memory is allocated and freed correctly, but the pattern of allocations and deallocations leaves the heap looking like Swiss cheese. Total free memory stays roughly constant, but the largest contiguous block shrinks over time. The fix: change your allocation patterns.

Here’s the visual:

Unfragmented:
[USED][USED][...........FREE............]
                  ↑ one big block, easy to allocate from

Fragmented (same total free bytes):
[USED][free][USED][free][USED][free][USED][free]
              ↑ no single block big enough for your 8 KB buffer

If your free heap is decreasing over time, you have a leak, not fragmentation. Look for ESP32 memory leak detection guides instead. If free heap is stable but allocations are failing, keep reading.

Why the ESP32 Is Especially Vulnerable to Heap Fragmentation

The ESP32 isn’t a Linux box with virtual memory, an MMU, and gigabytes of RAM. It has roughly 320 KB of DRAM, and the heap allocator is a linked-list structure that spans non-contiguous physical memory regions. That architecture makes fragmentation bite faster and harder than you’d expect.

The multi-region heap is the first problem. ESP-IDF’s heap allocator manages DRAM, IRAM (if heap allocation from IRAM is enabled), and PSRAM (if present) as a unified heap. But these aren’t contiguous in the address space. A “free” region in IRAM can’t be merged with a “free” region in DRAM. You effectively have multiple smaller heaps masquerading as one, each independently fragmenting.

The second problem is what the ESP32 software stack does at runtime. Common patterns that accelerate fragmentation:

  • WiFi and BLE stacks internally allocate and free variably-sized buffers constantly. You don’t control this, and it happens on every packet.
  • JSON parsing (especially cJSON) issues dozens of small malloc calls per parse: one per key, one per value, one per node.
  • String building with realloc creates a cascade of growing-then-abandoned blocks.
  • TLS handshakes allocate large temporary buffers (4–16 KB), use them briefly, then free them, leaving holes in the middle of the heap.
  • FreeRTOS task creation via xTaskCreate allocates task stacks from the heap. If you dynamically create and delete tasks, each one punches a hole.

On a system with 320 KB of DRAM and no virtual memory to defragment behind the scenes, these patterns compound fast.

How to Detect ESP32 Heap Fragmentation

Stop checking esp_get_free_heap_size() and expecting it to tell the whole story. The number you actually need is heap_caps_get_largest_free_block().

Here’s a monitoring function you should add to every long-running ESP32 project:

void log_heap_status(void) {
    size_t free = esp_get_free_heap_size();
    size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT);
    float frag = 1.0f - ((float)largest / (float)free);
    ESP_LOGI("HEAP", "Free: %u | Largest block: %u | Frag: %.2f%%",
             free, largest, frag * 100.0f);
}

The fragmentation ratio, 1 - (largest_block / total_free), gives you a single number. Zero means all free memory is in one contiguous block. Closer to 1.0 means your free memory is scattered into tiny unusable pieces. Anything above 0.5 on an ESP32 should make you nervous.

Call this function on a timer (every 60 seconds is a good cadence) and log it over serial or MQTT during soak testing. You want a time series, not a snapshot.

For deeper debugging sessions, heap_caps_dump() and heap_caps_dump_all() will print the entire free-block list to the console. It’s verbose, but it shows you exactly where the holes are.

ESP-IDF also includes a heap tracing facility (CONFIG_HEAP_TRACING_DEST) that can record individual allocation and free events with call-site information. Enable it during development to identify which specific call sites are creating the fragmentation pattern.

Five Techniques to Prevent ESP32 Memory Fragmentation

Pre-allocate fixed buffers and reuse them

The simplest technique: allocate your big buffers once at boot, and never free them. Reuse them for the lifetime of the device.

static char *json_buf = NULL;

void app_main(void) {
    json_buf = heap_caps_malloc(4096, MALLOC_CAP_8BIT);
    assert(json_buf);
    // ... rest of init
}

void build_json_payload(void) {
    memset(json_buf, 0, 4096);
    snprintf(json_buf, 4096, "{\"temp\":%.1f}", read_temp());
    mqtt_publish(json_buf);
}

No malloc in the hot path. No free. No fragmentation contribution. This alone fixes the majority of fragmentation issues I see in production ESP32 firmware.

Use memory pools for same-sized objects

When your code allocates and frees many objects of the same size (sensor readings, protocol messages, queue items) a memory pool eliminates fragmentation entirely for those objects.

#define POOL_SIZE  20
#define BLOCK_SIZE sizeof(sensor_reading_t)

static uint8_t pool_mem[POOL_SIZE][BLOCK_SIZE];
static QueueHandle_t pool_queue;

void pool_init(void) {
    pool_queue = xQueueCreate(POOL_SIZE, sizeof(void *));
    for (int i = 0; i < POOL_SIZE; i++) {
        void *ptr = &pool_mem[i];
        xQueueSend(pool_queue, &ptr, 0);
    }
}

void *pool_alloc(void) {
    void *ptr;
    return xQueueReceive(pool_queue, &ptr, 0) ? ptr : NULL;
}

void pool_free(void *ptr) {
    xQueueSend(pool_queue, &ptr, 0);
}

The pool memory is statically allocated and never interacts with the heap at all. You get O(1) alloc and free with zero fragmentation cost. Use this pattern anywhere you’re doing frequent same-sized allocations.

Control allocation ordering: long-lived first

This is an architectural discipline, not a code trick. At boot, allocate things in this order:

  1. Permanent buffers — DMA buffers, TLS contexts, protocol buffers that live forever
  2. Task stacks — all xTaskCreate calls for persistent tasks
  3. Driver initialization — WiFi, BLE, SPI (these allocate internal buffers)
  4. Only then start transient work — request handling, sensor reads, message processing

This pushes all long-lived allocations to one end of the heap. Transient allocations happen in the remaining contiguous region, where they can be allocated and freed without leaving permanent holes between permanent blocks.

Use heap_caps_malloc with explicit capability flags

Stop using raw malloc on ESP32. Use heap_caps_malloc to direct allocations to the right memory region and prevent large allocations from fragmenting precious internal DRAM.

// Large buffer → PSRAM (keeps internal DRAM clean)
uint8_t *camera_buf = heap_caps_malloc(32768, MALLOC_CAP_SPIRAM);

// DMA buffer → must be DMA-capable internal memory
uint8_t *spi_buf = heap_caps_malloc(512, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);

// Performance-critical struct → internal DRAM explicitly
control_state_t *state = heap_caps_malloc(sizeof(control_state_t), MALLOC_CAP_INTERNAL);

If you have PSRAM configured, this is critical. Without explicit caps, malloc may place a large buffer in internal DRAM when it could have gone to PSRAM, fragmenting the region you need for WiFi buffers and task stacks.

Eliminate malloc-heavy patterns entirely

Some common libraries and patterns are fragmentation factories. Replace them:

  • cJSON tree building: use cJSON_PrintPreallocated(root, json_buf, buf_size, false) with a pre-allocated buffer instead of letting cJSON_Print malloc its own.
  • Repeated realloc for string building: pre-size a buffer to the maximum expected length. Overestimating by 200 bytes costs you nothing; fragmenting costs you a field return.
  • Dynamic xTaskCreate / vTaskDelete loops: create persistent tasks at boot that block on a queue, and send work items to them. Never create and delete tasks at runtime in a long-running system.
  • Per-request TLS connections: use persistent connections with keep-alive instead of tearing down and re-establishing TLS sessions.

Each of these changes reduces the number of variably-sized malloc/free cycles, which is exactly what drives fragmentation.

Soak Testing: Fragmentation Only Shows Up Over Time

You will not catch fragmentation in a 10-minute bench test. It accumulates over hours and days under realistic load. You must soak test.

The protocol: run your device for 7+ days under realistic conditions. Real sensor data, real network traffic, real reconnection cycles. Log heap_caps_get_largest_free_block() every 60 seconds. Plot it.

Largest Free Block Over Time

 50KB |****
      |    *****
 30KB |         *****
      |              ****
 10KB |                  ****_____ ← crash threshold
      +---------------------------
       Day 1   Day 3   Day 5

       ↑ This downward slope means fragmentation.

A healthy device shows a flat line. A fragmenting device shows a downward slope on largest_free_block even while total free heap stays constant. That divergence is your signal.

For CI integration, connect a device to a test runner (pytest with serial log parsing works well), let it run for the full soak period, and fail the test if the fragmentation ratio exceeds a threshold or if largest_free_block drops below your largest expected allocation size.

Building This Into Every ESP32 Project

Here’s your checklist. Print it. Tape it above your desk.

  1. Monitor largest_free_block, not just free heap. Add the logging function from this article to every project.
  2. Confirm it’s fragmentation, not a leak. Is free heap stable but largest block shrinking? Fragmentation. Is free heap decreasing? Leak.
  3. Pre-allocate and reuse large buffers. Allocate once at boot, reuse forever.
  4. Pool same-sized objects. Use a free-list or queue-based pool for frequently allocated/freed structs.
  5. Boot-order discipline. Long-lived allocations first, transient work after.
  6. Use heap_caps_malloc everywhere. Direct allocations to the right memory region explicitly.
  7. Eliminate malloc-heavy libraries or use their preallocated variants.
  8. Soak test for 7+ days with heap metrics logging. Plot largest_free_block over time.

Fragmentation isn’t mysterious. It’s the predictable result of mixed-size allocations and frees on a small heap with no virtual memory. Once you see it for what it is, the fixes are straightforward, and your devices stop crashing on day three.

For more ESP32 development patterns and debugging techniques, explore additional ESP32 development resources.


Hubble Network connects your ESP32 devices directly to satellite — no gateways, no infrastructure to maintain. See how it works →