All posts
esp32architecture

Event-driven firmware on ESP32: why polling is killing your code

· 15 min read NEW

Open any ESP32 tutorial and there it is — the polling loop. Read a pin, check a flag, call a function, repeat. Simple, obvious, and the source of an entire category of firmware problems that will haunt you six months after the device ships.

Here is a realistic polling loop, the kind that starts as three lines and grows to fifty:

uint32_t lastSensorRead    = 0;
uint32_t lastMqttCheck     = 0;
uint32_t lastButtonCheck   = 0;
uint32_t lastWifiCheck     = 0;
bool     buttonLastState   = false;
bool     sensorDataReady   = false;

void loop() {
  uint32_t now = millis();

  // Check WiFi every 5 seconds
  if (now - lastWifiCheck >= 5000) {
    lastWifiCheck = now;
    if (WiFi.status() != WL_CONNECTED) {
      Serial.println("WiFi disconnected, reconnecting...");
      WiFi.reconnect();
    }
  }

  // Check button every 20ms (debounce)
  if (now - lastButtonCheck >= 20) {
    lastButtonCheck = now;
    bool buttonState = digitalRead(BUTTON_PIN) == LOW;
    if (buttonState && !buttonLastState) {
      Serial.println("Button pressed!");
      // Do button thing
      displayMenuPage++;
    }
    buttonLastState = buttonState;
  }

  // Read sensor every 2 seconds
  if (now - lastSensorRead >= 2000) {
    lastSensorRead = now;
    if (bme280.takeForcedMeasurement()) {
      gTemperature = bme280.readTemperature();
      gHumidity    = bme280.readHumidity();
      sensorDataReady = true;
    }
  }

  // Check MQTT and publish pending data
  if (mqtt.connected()) {
    mqtt.loop();
    if (sensorDataReady) {
      sensorDataReady = false;
      char payload[128];
      snprintf(payload, sizeof(payload),
               "{\"temp\":%.1f,\"humidity\":%.1f}",
               gTemperature, gHumidity);
      mqtt.publish("sensor/data", payload);
    }
  } else {
    if (now - lastMqttCheck >= 10000) {
      lastMqttCheck = now;
      mqtt.connect(CLIENT_ID);
    }
  }
}

This works. For a while. Then you need to add OTA update polling. Then a display. Then a second button. Then someone asks why pressing the button during a sensor read causes a 200ms delay in the display update. Then you realise that if the sensor read blocks for 100ms, the button debounce misses presses entirely.

The problem is structural. You have built a sequential system where everything competes for the same thread of execution, tightly coupled through shared timing variables and global state flags.

Why interrupts alone are not the answer

The obvious fix for button latency is an interrupt. And yes, you should use an interrupt for buttons. But interrupts do not solve the coupling problem — they make it worse if misused.

An ISR has hard constraints:

  • Cannot call most Arduino library functions (Serial.print, Wire, SPI, mqtt.publish)
  • Cannot use delay() or any blocking call
  • Must complete in microseconds (hundreds at most — the Interrupt Watchdog fires at 300ms)
  • If the ISR directly calls a function, the ISR now knows about that function’s interface
void IRAM_ATTR buttonISR() {
  // This looks reasonable but is wrong:
  displayMenuPage++;    // Race condition with loop() reading this
  mqtt.publish("button/press", "1");  // WRONG: not ISR-safe
}

The ISR-to-function coupling problem: the button ISR now directly calls an MQTT function. The button driver knows about MQTT. Change the MQTT interface and you must change the button ISR. This is the same tight coupling problem as the polling loop, just harder to debug because it involves interrupt context.

What you want is a system where the button says “a button press occurred” and walks away — without knowing or caring who handles it.

What event-driven actually means

Event-driven systems have three participants:

  1. Producers emit events without knowing who will handle them. A button ISR emits “button pressed”. A sensor task emits “temperature reading: 23.5°C”. They do not call any handler directly.

  2. Consumers register interest in events without knowing who produces them. An MQTT task handles “temperature reading” events. A display task also handles “temperature reading” events. Neither knows the sensor task exists.

  3. An event queue in the middle decouples them. Producers push events in. Consumers pull events out (or are called when events arrive). The queue absorbs timing differences.

This is not a new idea. It is how every GUI framework works. Qt’s signals and slots. Node.js’s event emitter. Windows messages. The EventListener in browsers. The idea is decades old. It works on microcontrollers too.

Building a minimal event bus in C++

Start with the event type:

#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <functional>
#include <map>
#include <vector>

// All possible event types in the system
enum class EventType {
  BUTTON_PRESS,
  BUTTON_RELEASE,
  SENSOR_READING,
  NETWORK_STATUS_CHANGE,
  MQTT_MESSAGE,
  DEBOUNCED_BUTTON_PRESS,
  STATE_CHANGE,
  MQTT_PUBLISH_REQUEST
};

// Network status sub-values
enum class NetworkStatus { CONNECTED, DISCONNECTED, RECONNECTING };

// MQTT message data
struct MqttMessageData {
  char topic[64];
  char payload[256];
};

// Sensor reading data
struct SensorData {
  float temperature;
  float humidity;
  float pressure;
};

// The event itself: a type tag plus a data union
struct Event {
  EventType type;
  union {
    uint8_t        gpioNumber;        // BUTTON_PRESS, BUTTON_RELEASE
    SensorData     sensor;            // SENSOR_READING
    NetworkStatus  networkStatus;     // NETWORK_STATUS_CHANGE
    MqttMessageData mqttMessage;      // MQTT_MESSAGE, MQTT_PUBLISH_REQUEST
    int            stateValue;        // STATE_CHANGE
  } data;
};

Now the event bus itself:

// Callback type: a function that takes a const Event reference
using EventCallback = std::function<void(const Event&)>;

class EventBus {
public:
  EventBus(size_t queueDepth = 32) {
    _queue = xQueueCreate(queueDepth, sizeof(Event));
    configASSERT(_queue != nullptr);
  }

  // Register a callback for an event type
  // Returns a handle that can be used to unsubscribe (index into vector)
  int subscribe(EventType type, EventCallback callback) {
    _subscribers[type].push_back(callback);
    return (int)_subscribers[type].size() - 1;
  }

  // Publish an event — safe to call from any task
  bool publish(const Event& event) {
    return xQueueSend(_queue, &event, 0) == pdTRUE;
  }

  // ISR-safe publish — call from interrupt handlers
  bool publishFromISR(const Event& event) {
    BaseType_t higherPriorityTaskWoken = pdFALSE;
    bool sent = xQueueSendFromISR(_queue, &event, &higherPriorityTaskWoken) == pdTRUE;
    if (higherPriorityTaskWoken) portYIELD_FROM_ISR();
    return sent;
  }

  // Dispatch one event from the queue. Returns false if queue empty.
  // Call in a loop from the dispatcher task, or use dispatchBlocking().
  bool dispatch() {
    Event event;
    if (xQueueReceive(_queue, &event, 0) == pdTRUE) {
      auto it = _subscribers.find(event.type);
      if (it != _subscribers.end()) {
        for (auto& cb : it->second) {
          cb(event);
        }
      }
      return true;
    }
    return false;
  }

  // Block waiting for the next event, then dispatch it.
  // Use this in the dispatcher task's main loop.
  void dispatchBlocking(TickType_t waitTicks = portMAX_DELAY) {
    Event event;
    if (xQueueReceive(_queue, &event, waitTicks) == pdTRUE) {
      auto it = _subscribers.find(event.type);
      if (it != _subscribers.end()) {
        for (auto& cb : it->second) {
          cb(event);
        }
      }
    }
  }

  size_t pending() const {
    return uxQueueMessagesWaiting(_queue);
  }

private:
  QueueHandle_t                               _queue;
  std::map<EventType, std::vector<EventCallback>> _subscribers;
};

// Global event bus — accessible from all compilation units
EventBus g_bus;

The dispatcher task

The event bus needs a task that pumps events through it:

void dispatcherTask(void* parameter) {
  for (;;) {
    // Block until an event arrives, then dispatch it to all subscribers
    g_bus.dispatchBlocking(portMAX_DELAY);
  }
}

One dispatcher or many? A single dispatcher task is simpler but means all callbacks run sequentially in the dispatcher’s context. If one callback blocks, other events pile up. For most firmware this is fine. If you have callbacks with very different timing requirements, use separate queues and dispatchers per subsystem.

Decoupling example: sensor to MQTT to display

Here is the pattern in practice. Three tasks that know nothing about each other:

// ── Sensor task ───────────────────────────────────────────────────────────────
// Reads BME280 every 10 seconds, publishes SENSOR_READING event.
// Knows nothing about MQTT or display.
void sensorTask(void* parameter) {
  Adafruit_BME280 bme;
  if (!bme.begin(0x76)) {
    Serial.println("[Sensor] BME280 not found");
    vTaskDelete(NULL);
    return;
  }

  for (;;) {
    Event e;
    e.type                 = EventType::SENSOR_READING;
    e.data.sensor.temperature = bme.readTemperature();
    e.data.sensor.humidity    = bme.readHumidity();
    e.data.sensor.pressure    = bme.readPressure() / 100.0f;

    g_bus.publish(e);
    Serial.printf("[Sensor] Published: %.1f°C  %.1f%%  %.1fhPa\n",
                  e.data.sensor.temperature,
                  e.data.sensor.humidity,
                  e.data.sensor.pressure);

    vTaskDelay(pdMS_TO_TICKS(10000));
  }
}

// ── MQTT handler ──────────────────────────────────────────────────────────────
// Subscribes to SENSOR_READING, formats and queues for publish.
// Knows nothing about sensors or display.
void setupMqttHandler() {
  g_bus.subscribe(EventType::SENSOR_READING, [](const Event& e) {
    char payload[128];
    snprintf(payload, sizeof(payload),
             "{\"temp\":%.1f,\"humidity\":%.1f,\"pressure\":%.1f}",
             e.data.sensor.temperature,
             e.data.sensor.humidity,
             e.data.sensor.pressure);

    // Queue a publish request — don't call mqtt.publish() here
    // because we're in the dispatcher task context, not the MQTT task
    Event pub;
    pub.type = EventType::MQTT_PUBLISH_REQUEST;
    strncpy(pub.data.mqttMessage.topic,   "sensor/bme280", 64);
    strncpy(pub.data.mqttMessage.payload, payload, 256);
    g_bus.publish(pub);
  });
}

// ── Display handler ───────────────────────────────────────────────────────────
// Also subscribes to SENSOR_READING. Sensor task didn't change.
// MQTT handler didn't change. Just add another subscriber.
void setupDisplayHandler() {
  g_bus.subscribe(EventType::SENSOR_READING, [](const Event& e) {
    // Update display — called in dispatcher task context
    // Keep this fast; don't block here
    char line[32];
    snprintf(line, sizeof(line), "%.1fC  %.0f%%", 
             e.data.sensor.temperature, e.data.sensor.humidity);
    Serial.printf("[Display] Update: %s\n", line);
    // display.setCursor(0,0); display.print(line); display.display();
  });
}

Adding the display subscriber required zero changes to the sensor task or the MQTT handler. That is the payoff.

Button to debounce to state machine: a complete chain

Here is the full event chain the introduction mentioned: button press → debounce → state change → MQTT publish.

// ── Debounce handler ──────────────────────────────────────────────────────────
// Subscribes to raw BUTTON_PRESS events, filters bounces,
// emits DEBOUNCED_BUTTON_PRESS events.
static uint32_t lastDebounceTime = 0;
static const uint32_t DEBOUNCE_MS = 50;

void setupDebounceHandler() {
  g_bus.subscribe(EventType::BUTTON_PRESS, [](const Event& e) {
    uint32_t now = millis();
    if (now - lastDebounceTime < DEBOUNCE_MS) return;  // Filter bounce
    lastDebounceTime = now;

    // Emit a clean, debounced event
    Event debounced;
    debounced.type            = EventType::DEBOUNCED_BUTTON_PRESS;
    debounced.data.gpioNumber = e.data.gpioNumber;
    g_bus.publish(debounced);
  });
}

// ── Connection state handler ──────────────────────────────────────────────────
// Subscribes to DEBOUNCED_BUTTON_PRESS, responds to button 0
// by toggling a connection-related state.
static int g_connectionState = 0;

void setupConnectionHandler() {
  g_bus.subscribe(EventType::DEBOUNCED_BUTTON_PRESS, [](const Event& e) {
    if (e.data.gpioNumber != 0) return;  // Only handle GPIO 0 (BOOT button)

    g_connectionState = (g_connectionState == 0) ? 1 : 0;
    Serial.printf("[Connection] State toggled to: %d\n", g_connectionState);

    // Announce the state change
    Event stateEvent;
    stateEvent.type            = EventType::STATE_CHANGE;
    stateEvent.data.stateValue = g_connectionState;
    g_bus.publish(stateEvent);
  });
}

// ── MQTT publish handler ──────────────────────────────────────────────────────
// Subscribes to STATE_CHANGE, publishes to MQTT.
// Runs in a dedicated task so mqtt.publish() is safe.
static QueueHandle_t g_mqttPublishQueue;

void setupStateChangeMqttHandler() {
  g_bus.subscribe(EventType::STATE_CHANGE, [](const Event& e) {
    // Post to MQTT publish queue (handled in MQTT task)
    Event pub;
    pub.type = EventType::MQTT_PUBLISH_REQUEST;
    strncpy(pub.data.mqttMessage.topic, "device/state", 64);
    snprintf(pub.data.mqttMessage.payload, 256, "{\"state\":%d}", e.data.stateValue);
    g_bus.publish(pub);
  });
}

// ── Button ISR ────────────────────────────────────────────────────────────────
static const int BUTTON_PIN = 0;  // BOOT button on most ESP32 boards

void IRAM_ATTR buttonISR() {
  Event e;
  e.type            = EventType::BUTTON_PRESS;
  e.data.gpioNumber = BUTTON_PIN;
  g_bus.publishFromISR(e);  // ISR-safe push to queue
}

// ── MQTT task ─────────────────────────────────────────────────────────────────
WiFiClient   wifiClient;
PubSubClient mqtt(wifiClient);

void mqttTask(void* parameter) {
  mqtt.setServer("192.168.1.10", 1883);

  // Subscribe to MQTT_PUBLISH_REQUEST events in the MQTT task context
  // We can't subscribe during setup() because std::function captures context;
  // instead we poll the event bus from this task directly.
  // For simplicity here: use a dedicated queue fed by the event bus subscriber.
  g_mqttPublishQueue = xQueueCreate(16, sizeof(Event));

  // Register subscriber that feeds MQTT publish queue
  g_bus.subscribe(EventType::MQTT_PUBLISH_REQUEST, [](const Event& e) {
    xQueueSend(g_mqttPublishQueue, &e, 0);
  });

  for (;;) {
    // Maintain connection
    if (!mqtt.connected()) {
      if (WiFi.status() == WL_CONNECTED) {
        mqtt.connect("esp32-eventbus");
      }
    }
    mqtt.loop();

    // Process pending publish requests
    Event pubEvent;
    while (xQueueReceive(g_mqttPublishQueue, &pubEvent, 0) == pdTRUE) {
      if (mqtt.connected()) {
        mqtt.publish(pubEvent.data.mqttMessage.topic,
                     pubEvent.data.mqttMessage.payload);
        Serial.printf("[MQTT] Published to %s: %s\n",
                      pubEvent.data.mqttMessage.topic,
                      pubEvent.data.mqttMessage.payload);
      }
    }

    vTaskDelay(pdMS_TO_TICKS(10));
  }
}

// ── Setup ─────────────────────────────────────────────────────────────────────
void setup() {
  Serial.begin(115200);
  Serial.println("[Boot] ESP32 event-driven firmware example");

  // WiFi
  WiFi.begin("YourSSID", "YourPassword");

  // Register all event handlers
  setupDebounceHandler();
  setupConnectionHandler();
  setupStateChangeMqttHandler();
  setupMqttHandler();
  setupDisplayHandler();

  // Button interrupt — only emits an event, does no real work
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);

  // Launch tasks
  xTaskCreatePinnedToCore(dispatcherTask, "dispatch", 8192, NULL, 5, NULL, 0);
  xTaskCreatePinnedToCore(sensorTask,     "sensor",   4096, NULL, 2, NULL, 1);
  xTaskCreatePinnedToCore(mqttTask,       "mqtt",     8192, NULL, 1, NULL, 0);
}

void loop() {
  vTaskDelay(pdMS_TO_TICKS(1000));
}

The event flow is:

GPIO interrupt  →  BUTTON_PRESS event (via ISR-safe queue)

               Debounce handler  →  DEBOUNCED_BUTTON_PRESS event

                               Connection handler  →  STATE_CHANGE event

                                                  MQTT state handler  →  MQTT_PUBLISH_REQUEST

                                                                        MQTT task publishes

Each arrow is a queue push. Each handler knows only about the event it subscribes to. None of them know about each other.

The limitations of hand-rolled event buses

The system above works well up to a few dozen event types. Then you start hitting friction:

No type safety. The Event union means you can publish a SENSOR_READING event but accidentally read event.data.gpioNumber in the handler instead of event.data.sensor.temperature. This is a runtime crash, not a compile error.

The union grows unwieldy. Every new event type that carries data needs a member in the union. The union size is the largest member, so one large struct bloats every event. You end up with a 300-byte Event struct and a queue that can only hold 10 events before running out of RAM.

No priority. A watchdog-reset-critical event sits behind 15 telemetry publishes in the same queue. You work around this with multiple queues and multiple dispatchers — reinventing priority queues by hand.

ISR-safe emission is manual. You must remember to call publishFromISR() from ISRs and publish() everywhere else. Calling the wrong one causes an assertion failure at runtime.

No deferred dispatch. Callbacks are called synchronously in the dispatcher task. A slow callback blocks all other events. You work around this by having callbacks post to secondary queues — now you have queues of queues.

Each one of these problems is solvable. But each solution adds a special case to the EventBus. Six months in, your EventBus is five hundred lines of infrastructure code.

This is exactly the problem AdvancedSignalSlot solves — it brings the Qt signals/slots model to ESP32 and Arduino with compile-time type safety, priority queues, and ISR-safe emission, without the Qt framework overhead. Worth a look when your hand-rolled event bus starts growing its own appendages.

Comparing approaches

FeaturePolling loopISR + direct callHand-rolled EventBusAdvancedSignalSlot
Producer/consumer decouplingNoNoYesYes
ISR-safe emissionN/ARiskyManualBuilt-in
Type safetyN/AYesNoYes
Priority eventsN/AN/AManualBuilt-in
CPU usage at idleHighLowLowLow
RAM per eventSmallN/AFixed union sizeTemplated
ComplexityLowLowMediumMedium

What’s next

You now have four tools: RS485/Modbus for talking to industrial sensors, a finite state machine for managing complex system state, a watchdog timer for recovering from failures, and an event-driven architecture for decoupling components. The next post ties these together into a production firmware architecture pattern — how they compose into a system that is testable, maintainable, and deployable to devices you cannot physically access.


The event-driven pattern shown here is a starting point. Real production firmware will want to handle backpressure (what happens when the queue fills?), event replay for debugging, and structured logging that correlates events with timestamps. These are the problems worth solving once the basic architecture is in place.

Related posts

esp32architecture
Jun 19, 2026
esp32architecture
Jun 19, 2026
esp32arduinoarchitecture
Jun 18, 2026

Comments

Enjoyed this tutorial?

Get new ESP32, Arduino, and industrial IoT tutorials straight to your inbox — no spam, unsubscribe anytime.