El síntoma es desconcertante: el ESP32 se conecta al Wi-Fi, abre la conexión TLS con AWS IoT Core, se conecta por MQTT… y un segundo después AWS lo desconecta. En los registros de AWS el motivo es CLIENT_ERROR. No hay reinicio, no hay Guru Meditation, y el mismo firmware funciona perfecto en un ESP32-S3.
En mi caso la causa fue la memoria de pila (stack) de la tarea que ejecuta loop(). Si es tu primera vez con esta placa, tienes la guía del ESP32.
Qué significa CLIENT_ERROR
AWS IoT usa CLIENT_ERROR cuando el dispositivo hace algo que no respeta el protocolo: un paquete MQTT mal formado, un tópico inválido, un mensaje que no se puede interpretar. Si tu código no cambió y en otra placa funciona, ese "paquete mal formado" suele venir de memoria corrupta: el búfer que se envía tiene basura.
Para ver el motivo, suscríbete en el cliente de pruebas MQTT de la consola de AWS IoT a:
$aws/events/presence/disconnected/+
El campo disconnectReason lo dice.
Por qué se corrompe la memoria
En el núcleo de Arduino para ESP32, setup() y loop() se ejecutan dentro de una tarea de FreeRTOS con una pila de 8 KB por defecto. Todo lo que declaras como variable local dentro de esas funciones (y de las que llamas) vive ahí.
Una conexión a AWS IoT gasta mucha pila de golpe:
- el handshake TLS de
WiFiClientSecure; - el cliente MQTT y sus búferes;
- los documentos de ArduinoJson declarados como locales;
- un servidor web o un
HTTPClient, si además los usas.
Si la pila se desborda, pisa memoria vecina. A veces el sistema lo detecta y reinicia (Stack canary watchpoint triggered (loopTask)), pero otras veces solo corrompe datos y sigue funcionando… mal.
¿Por qué en el S3 no pasaba? La carga era la misma; el C3 tiene menos margen de RAM y una arquitectura distinta (RISC-V de un núcleo frente a Xtensa de dos), y la disposición de la memoria cambia. No lo tomes como regla: si tu firmware hace mucho, mide en cualquier placa.
Cómo medir la pila que te queda
void loop() {
static uint32_t ultimo = 0;
if (millis() - ultimo > 5000) {
ultimo = millis();
// Mínimo de pila libre que ha tenido esta tarea desde que arrancó (en bytes)
Serial.printf("Pila libre mínima: %u bytes\n", uxTaskGetStackHighWaterMark(NULL));
Serial.printf("Heap libre: %u bytes\n", ESP.getFreeHeap());
}
// ... tu código
}
Si la "pila libre mínima" baja de unos cientos de bytes después de conectar a AWS, estás al límite.

La solución: más pila para loop()
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <MQTT.h>
#include <ArduinoJson.h>
SET_LOOP_TASK_STACK_SIZE(16 * 1024); // 16 KB para setup() y loop()
void setup() { /* ... */ }
void loop() { /* ... */ }
Dos detalles:
- Va fuera de cualquier función, a nivel global.
- Va después de todos los
#include. Esa macro se expande a una función, y si queda antes de un#includecon tus tipos, provoca el error "was not declared in this scope".
Con 16 KB, el mismo firmware dejó de desconectarse en el C3.
Otras formas de gastar menos pila
Ampliar la pila es lo rápido. Si además quieres margen:
- Documentos JSON grandes fuera de la pila. Decláralos globales o
static, en lugar de locales dentro de una función que se llama a menudo. - Búferes grandes como globales. Un
char buffer[2048]local se come 2 KB de pila cada vez. - Una tarea propia con
xTaskCreate()y el tamaño de pila que necesites, para la parte de red.
¿ESP32-S3 o ESP32-C3 para esto?
El C3 sirve como dispositivo conectado a AWS si ajustas la pila, pero tiene menos margen para crecer. Para el dispositivo que hace de puente con la nube prefiero el S3; los nodos sencillos pueden ser C3. Lo comparo en ESP32-S3 vs ESP32-C3.
La conexión completa está en conectar un ESP32 a AWS IoT Core paso a paso, y este error es uno de los 13 errores reales de mi proyecto de domótica.
Preguntas frecuentes
¿Cuánta pila debo poner?
16 KB resolvió una carga de TLS + MQTT + JSON + servidor web. Mide con uxTaskGetStackHighWaterMark() y deja al menos 1-2 KB libres en el peor momento.
¿Gastar más pila reduce la RAM disponible?
Sí, esos bytes se reservan al arrancar. En un ESP32-C3 con unos 400 KB de RAM, 8 KB extra rara vez es un problema.
¿Funciona igual con PlatformIO?
Sí, si usas el framework Arduino. En ESP-IDF puro el tamaño se configura con CONFIG_ESP_MAIN_TASK_STACK_SIZE en menuconfig.