Utility Bar

ESP32 ESP-NOW Tutorial (Arduino IDE) – Send Data Between Boards Without Wi-Fi

ESP32 ESP-NOW Tutorial

Ethan Zaitchik |

Most ESP32 wireless projects rely on a Wi-Fi router to pass messages between devices. The ESP32 connects to the network, the router handles routing, and everything communicates through that central hub. This works well for internet-connected applications, but it introduces a dependency that is not always practical, what happens when there is no router nearby, or when you need two boards to communicate instantly without any infrastructure at all?

ESP-NOW is Espressif's answer to this problem. It is a connectionless wireless protocol built directly into the ESP32 that allows boards to send data to each other using nothing more than their MAC addresses. There is no router involved, no broker, no Wi-Fi network, and no internet connection required. Two ESP32 boards within range can begin exchanging data immediately after being powered on.

Table of Contents
    This tutorial is part of the ESP32 IoT Systems Pathway. If you are new to ESP32 or have not yet set up the Arduino IDE, start with our tutorial on Installing and Configuring Arduino IDE for ESP32 before continuing.
    What you'll build:
    Three progressively more capable ESP-NOW systems: a one-way sender and receiver that exchanges structured data, a two-way communication setup where both boards send and receive, and a one-to-many broadcast where a single sender reaches multiple receivers simultaneously.

    How ESP-NOW Works

    ESP-NOW operates at the Wi-Fi MAC layer, which sits below the standard TCP/IP stack used for internet communication. This means it bypasses the connection handshake, IP addressing, and routing that normal Wi-Fi requires. Instead, each ESP32 identifies other devices using their unique 6-byte MAC address and sends small packets directly to them.

    Because there is no connection to establish and no router to route through, messages are delivered extremely quickly, typically within a few milliseconds. The protocol also supports delivery confirmation, meaning the sending board can know whether each message was successfully received.

    ESP-NOW vs Wi-Fi vs MQTT

    Understanding when to use ESP-NOW versus the approaches covered in earlier guides comes down to three questions: does the system need internet access, how far apart are the devices, and how much data needs to be sent?

    Feature ESP-NOW Wi-Fi Web Server MQTT
    Router required No Yes Yes
    Internet required No No Yes (cloud broker)
    Max payload 250 bytes Unlimited Unlimited
    Latency Very low (~ms) Low Low–Medium
    Range (outdoors) ~220m Limited by router Limited by router
    Number of peers Up to 20 N/A Unlimited
    Best used for Board-to-board communication, no infrastructure Browser-based control panels Cloud dashboards, remote monitoring
    When to choose ESP-NOW:
    Use ESP-NOW when your devices need to talk directly to each other without any network infrastructure. For example, a remote sensor node sending readings to a central ESP32, a wireless button triggering an action on another board, or a mesh of devices in a location with no router. Interested in the alternative systems mentioned above? Check out our MQTT on ESP32 tutorial or our ESP32 Wi-Fi Web Server tutorial.

    Peers and MAC Addresses

    Before one ESP32 can send a message to another, it must register the target device as a peer. A peer is a known device identified by its MAC address, a unique 6-byte hardware identifier assigned to every Wi-Fi device at the factory. All modern devices, including the one you're using right now has its own unique MAC.

    Once a peer is registered, the sending board can transmit data to it at any time without any handshake or reconnection. The receiving board does not need to register the sender as a peer in order to receive messages, but it does need to register the sender if it wants to send a reply.

    Getting the MAC address of each board is therefore the first important step in any ESP-NOW project, which we will be covering.

    Message Structure

    ESP-NOW transmits raw bytes with a maximum payload of 250 bytes per message. In practice, the cleanest way to use this is to define a struct in your Arduino code that contains all the variables you want to send, then pass that struct directly to the send function. The receiving board defines an identical struct and unpacks the incoming bytes back into named variables.

    This approach, using structs rather than plain strings, is the standard pattern for real ESP-NOW projects because it lets you send multiple values of different types (integers, floats, booleans) in a single transmission without any manual string parsing.

    The 250 byte limit means ESP-NOW is not suited for sending large payloads such as sensor history, images, or long strings. For anything that requires more data per message, MQTT or an HTTP client is the better choice.

    Delivery Confirmation

    Every ESP-NOW transmission triggers a callback function on the sending board after the attempt completes. This callback receives the MAC address of the target peer and a status value indicating whether the message was successfully delivered or failed.

    This confirmation happens at the radio level, it does not mean the receiving board processed the message, only that it acknowledged receipt at the hardware layer. This is still significantly more feedback than most simple wireless protocols provide, and it is useful for detecting when a peer has gone out of range or lost power.

    Communication Architectures

    ESP-NOW supports three main communication patterns. This guide covers all three.

    One-to-One (One-Way)
    A single sender transmits data to a single receiver. The receiver does not respond. This is the simplest configuration and is useful for sensor nodes that only need to report data.

    ESP-NOW One to One

    Two-Way (Bidirectional)
    Both boards are registered as peers of each other. Either board can initiate a transmission at any time, and both boards can respond. This is used when devices need to exchange commands and acknowledgements.

    Two-Way (Bidirectional)

    One-to-Many (Broadcast)
    A single sender transmits to multiple receivers simultaneously. Each receiver is registered as a separate peer, or the broadcast MAC address is used to reach all nearby ESP-NOW devices at once. This is useful for systems where one controller needs to update several nodes at the same time.

    ESP-NOW One-to-Many (Broadcast)

    Which architecture should you use?
    Start with one-way communication to verify your boards can find each other and exchange data. Once that is working, two-way and one-to-many are straightforward extensions of the same foundation, the core concepts do not change, only the peer registration and callback logic.

    Getting Your ESP32 MAC Address

    Every ESP32 board has a unique MAC address burned into its hardware at the factory. ESP-NOW uses this address to identify exactly which board to send data to, as there are no IP addresses, no hostnames, and no network configuration involved. Before writing any communication code, you need to know the MAC address of each board in your system.

    Upload the following sketch to each ESP32 board individually and note the address printed in the Serial Monitor. If you need help opening and configuring the Serial Monitor, follow our Installing Libraries & Serial Monitor on ESP32 tutorial.

    #include <WiFi.h>
    
    void setup() {
      Serial.begin(115200);
    delay(1000);   WiFi.mode(WIFI_STA);
    delay(100);   Serial.print("MAC Address: "); Serial.println(WiFi.macAddress()); } void loop() { }

    Open the Serial Monitor at 115200 baud. The MAC address will be printed in the format XX:XX:XX:XX:XX:XX, six pairs of hexadecimal characters separated by colons.

    ESP32 MAC Address in Serial Monitor
    Write down the MAC address of each board before continuing and don't mix them. A single incorrect character in the address will prevent the boards from communicating. MAC addresses are case-insensitive but the colon-separated format must be preserved when entering them into your code.
    Why does WiFi.mode(WIFI_STA) appear here?
    ESP-NOW requires the ESP32's Wi-Fi radio to be active in Station mode before it can be initialised. Even though no router connection is made, the radio must be in the correct mode. This line will appear in all ESP-NOW sketches throughout this guide.

    Hardware Required

    At minimum you will need two ESP32 boards to follow this guide. Part 3 (one-to-many) requires a third board, but Parts 1 and 2 can be completed with two.

    Both boards must be connected to your computer at the same time so you can open a Serial Monitor for each one independently. In the Arduino IDE, use Tools → Port to select the correct port before uploading to each board.

    Get All the Parts You Need

    This tutorial is part of our comprehensive ESP32 learning series. Instead of buying components individually, save time and money with our ESP32 Basic Starter Kit. It includes everything you need for most lessons and 20+ other projects. This lesson requires an additional ESP32 board.

    What's Included: ESP32 board, OLED display, sensors (DHT11, PIR, LDR), relay module, buzzers, LEDs, buttons, breadboard, resistors, and all cables, plus access to our complete lesson plans.

    View ESP32 Starter Kit →

    Installing the ESP-NOW Library

    ESP-NOW is built directly into the ESP32 Arduino core, no additional library installation is required. As long as the ESP32 board package is installed in your Arduino IDE, the esp_now.h and WiFi.h headers are already available.

    Every ESP-NOW sketch in this guide requires these two includes at the top:

    #include <esp_now.h>
    #include <WiFi.h>
    Not seeing esp_now.h?
    If the Arduino IDE cannot find esp_now.h, your ESP32 board package may be outdated or not installed. Follow our guide on Installing and Configuring Arduino IDE for ESP32 to install the correct board package before continuing.

    Part 1 – One-Way Communication

    The simplest ESP-NOW configuration has one board acting purely as a sender and one acting purely as a receiver. The sender transmits a struct containing data at regular intervals. The receiver unpacks that struct and prints the values to the Serial Monitor.

    This is the foundation that Parts 2 and 3 build on. Get this working before moving forward.

    Understanding the Struct Pattern

    Rather than sending a plain text string, ESP-NOW projects should use a struct to package data. A struct lets you send multiple values of different types, an integer, a float, a boolean, in a single 250-byte transmission, without any manual string formatting or parsing on either end.

    Both the sender and receiver must define an identical struct. The sender fills it with values and passes it to the send function as raw bytes. The receiver casts the incoming bytes back into the same struct and reads the named fields directly. If the structs do not match exactly, the received data will be garbage.

    Throughout this guide we use the following struct as our example payload:

    typedef struct {
      int   counter;
      float temperature;
      bool  ledState;
    } MessageData;
    
    MessageData myData;

    This struct sends three values simultaneously: an incrementing counter, a temperature reading, and an LED state. You can replace these fields with whatever your project requires, as long as both boards use the same definition.

    Sender Code

    Upload this sketch to the board that will be sending data. Replace broadcastAddress with the MAC address of your receiver board.

    #include <esp_now.h>
    #include <WiFi.h>
    
    /* ===== Receiver MAC Address ===== */
    uint8_t broadcastAddress[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; // Replace the F characters with your receiver MAC
    
    /* ===== Data Structure ===== */
    typedef struct {
      int   counter;
      float temperature;
      bool  ledState;
    } MessageData;
    
    MessageData myData;
    
    esp_now_peer_info_t peerInfo;
    
    /* ===== Delivery Callback ===== */
    void onDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
      Serial.print("Delivery status: ");
      Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Success" : "Failed");
    }
    
    void setup() {
      Serial.begin(115200);
      WiFi.mode(WIFI_STA);
    
      // Initialise ESP-NOW
      if (esp_now_init() != ESP_OK) {
        Serial.println("ESP-NOW init failed");
        return;
      }
    
      // Register the delivery callback
      esp_now_register_send_cb(onDataSent);
    
      // Register the receiver as a peer
      memcpy(peerInfo.peer_addr, broadcastAddress, 6);
      peerInfo.channel = 0;
      peerInfo.encrypt = false;
    
      if (esp_now_add_peer(&peerInfo) != ESP_OK) {
        Serial.println("Failed to add peer");
        return;
      }
    }
    
    void loop() {
      // Populate the struct with values
      myData.counter++;
      myData.temperature = 24.6;
      myData.ledState = true;
    
      // Send the struct as raw bytes
      esp_now_send(broadcastAddress, (uint8_t *) &myData, sizeof(myData));
    
      Serial.print("Sent message #");
      Serial.println(myData.counter);
    
      delay(2000);
    }

    Receiver Code

    Upload this sketch to the second board. No changes are needed as the receiver does not need to know the sender's MAC address to receive messages.

    #include <esp_now.h>
    #include <WiFi.h>
    
    /* ===== Data Structure (must match sender exactly) ===== */
    typedef struct {
      int   counter;
      float temperature;
      bool  ledState;
    } MessageData;
    
    MessageData receivedData;
    
    /* ===== Receive Callback ===== */
    void onDataReceived(const esp_now_recv_info *recv_info, const uint8_t *incomingData, int len) {
      memcpy(&receivedData, incomingData, sizeof(receivedData));
    
      Serial.println("--- Message Received ---");
      Serial.print("Counter:     "); Serial.println(receivedData.counter);
      Serial.print("Temperature: "); Serial.println(receivedData.temperature);
      Serial.print("LED State:   "); Serial.println(receivedData.ledState ? "ON" : "OFF");
    }
    
    void setup() {
      Serial.begin(115200);
      WiFi.mode(WIFI_STA);
    
      if (esp_now_init() != ESP_OK) {
        Serial.println("ESP-NOW init failed");
        return;
      }
    
      // Register the receive callback
      esp_now_register_recv_cb(onDataReceived);
    }
    
    void loop() {
      // Nothing needed here - onDataReceived fires automatically
    }

    Understanding the Code

    The MAC address of the receiver is stored as an array of six bytes, matching the hardware format ESP-NOW expects.

    uint8_t broadcastAddress[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

    Replace each 0xFF pair with the corresponding byte from your receiver's MAC address. For example, if the Serial Monitor printed A4:CF:12:8E:3B:70, the array becomes:

    uint8_t broadcastAddress[] = {0xA4, 0xCF, 0x12, 0x8E, 0x3B, 0x70};

    The onDataSent callback fires automatically after every transmission attempt. It does not need to be called manually, ESP-NOW calls it for you once the radio-level delivery result is known.

    void onDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
      Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Success" : "Failed");
    }

    Registering the peer tells ESP-NOW which device the sender is allowed to transmit to. The channel field is set to 0, which means the ESP32 will use the current channel automatically. Encryption is disabled for clarity, it can be enabled later if needed.

    memcpy(peerInfo.peer_addr, broadcastAddress, 6);
    peerInfo.channel = 0;
    peerInfo.encrypt = false;
    esp_now_add_peer(&peerInfo);

    On the sender, data is transmitted by passing a pointer to the struct and its size. ESP-NOW reads the bytes starting at that memory address and sends exactly sizeof(myData) bytes.

    esp_now_send(broadcastAddress, (uint8_t *) &myData, sizeof(myData));

    On the receiver, the incoming bytes are copied back into the local struct using memcpy. Because both boards defined the same struct layout, the bytes map back to the correct fields automatically.

    memcpy(&receivedData, incomingData, sizeof(receivedData));

    The loop() function on the receiver is intentionally empty. The onDataReceived callback is triggered by an interrupt whenever a new packet arrives, there is nothing to poll.

    Expected Output

    With both boards running and their Serial Monitors open, you should see the following.

    Sender Serial Monitor:

    ESP-NOW sender sending message

    Receiver Serial Monitor:

    ESP-NOW receiver receiving message with counter, temperature & LED state

    A new message should appear on the receiver every two seconds, matching the delay(2000) in the sender's loop. The counter value on the receiver should increment with each message, confirming that every transmission is being delivered and received correctly.

    No output on the receiver?
    The most common cause is an incorrect MAC address in the sender code. Double-check each byte against the address printed when you ran the MAC address sketch on the receiver board. A single transposed digit will cause all transmissions to fail silently.

    Part 2 – Two-Way Communication

    In Part 1, data only flowed in one direction. The sender transmitted and the receiver listened, but the receiver had no way to respond. Two-way communication changes this, both boards can initiate transmissions, and both boards can reply.

    The key difference from Part 1 is that each board must now register the other as a peer. The sender already does this in Part 1, but the receiver does not, because it never needed to transmit. In Part 2, both boards run a combined sketch that handles sending and receiving simultaneously.

    How Two-Way ESP-NOW Works

    There is no concept of a "master" and "slave" in two-way ESP-NOW communication. Both boards are equal, either one can transmit at any time without waiting for permission. Each board registers the other as a peer during setup(), after which both the onDataSent and onDataReceived callbacks are active on each board simultaneously.

    In this example, each board sends an outgoing struct every two seconds and immediately prints any incoming struct it receives. The two transmissions are independent, where Board A does not wait for a reply from Board B before sending its next message.

    Both boards run identical code in this example. The only difference between them is the MAC address entered in the peerAddress array, each board must contain the MAC address of the other board, not its own.

    Two-Way Code (upload to both boards)

    Before uploading, replace peerAddress with the MAC address of the other board. Board A gets Board B's address, and Board B gets Board A's address.

    #include <esp_now.h>
    #include <WiFi.h>
    
    /* ===== MAC Address of the OTHER board ===== */
    uint8_t peerAddress[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; // Replace with the other board's MAC
    
    /* ===== Outgoing Data Structure ===== */
    typedef struct {
      int   counter;
      float temperature;
      bool  ledState;
    } MessageData;
    
    MessageData outgoing;
    MessageData incoming;
    
    esp_now_peer_info_t peerInfo;
    
    /* ===== Delivery Callback ===== */
    void onDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
      Serial.print("Delivery status: ");
      Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Success" : "Failed");
    }
    
    /* ===== Receive Callback ===== */
    void onDataReceived(const esp_now_recv_info *recv_info, const uint8_t *incomingData, int len) {
      memcpy(&incoming, incomingData, sizeof(incoming));
    
      Serial.println("--- Incoming Message ---");
      Serial.print("Counter:     "); Serial.println(incoming.counter);
      Serial.print("Temperature: "); Serial.println(incoming.temperature);
      Serial.print("LED State:   "); Serial.println(incoming.ledState ? "ON" : "OFF");
    }
    
    void setup() {
      Serial.begin(115200);
      WiFi.mode(WIFI_STA);
    
      if (esp_now_init() != ESP_OK) {
        Serial.println("ESP-NOW init failed");
        return;
      }
    
      esp_now_register_send_cb(onDataSent);
      esp_now_register_recv_cb(onDataReceived);
    
      // Register the other board as a peer
      memcpy(peerInfo.peer_addr, peerAddress, 6);
      peerInfo.channel = 0;
      peerInfo.encrypt = false;
    
      if (esp_now_add_peer(&peerInfo) != ESP_OK) {
        Serial.println("Failed to add peer");
        return;
      }
    }
    
    void loop() {
      // Populate and send outgoing struct
      outgoing.counter++;
      outgoing.temperature = 24.6;
      outgoing.ledState = (outgoing.counter % 2 == 0);
    
      esp_now_send(peerAddress, (uint8_t *) &outgoing, sizeof(outgoing));
    
      Serial.print("Sent message #");
      Serial.println(outgoing.counter);
    
      delay(2000);
    }

    What Changed from Part 1

    The struct definition is identical to Part 1. The meaningful differences are small but important.

    Two separate struct instances are now declared, one for outgoing data and one for incoming data. This prevents a received message from overwriting values that are about to be sent.

    MessageData outgoing;
    MessageData incoming;

    Both callbacks are now registered in setup(). In Part 1, the sender only registered onDataSent and the receiver only registered onDataReceived. Here, both are registered on each board because each board needs to do both.

    esp_now_register_send_cb(onDataSent);
    esp_now_register_recv_cb(onDataReceived);

    The ledState field now toggles on each transmission using the modulo operator, so you can clearly see the value alternating between ON and OFF on the receiving side and confirm messages are arriving in sequence.

    outgoing.ledState = (outgoing.counter % 2 == 0);

    Expected Output

    Both Serial Monitors should show a mix of outgoing confirmations and incoming message printouts, interleaved every two seconds. Because both boards transmit on the same interval, the outputs will appear roughly in sync, though the exact timing will drift slightly over time, which is normal.

    ESP-NOW Two Way Communication Demo
    Delivery shows Success but the other board receives nothing?
    This usually means you entered your own MAC address instead of the other board's. A "Success" delivery status only confirms the radio transmitted, it does not mean any device is listening. Swap the MAC addresses and re-upload to both boards.

    Part 3 – One-to-Many (Broadcasting)

    One-to-many communication allows a single sender to reach multiple receivers simultaneously. This is useful for systems where a central controller needs to push updates, including commands, settings, or sensor triggers, to several nodes at once without repeating the transmission for each one.

    There are two ways to do this in ESP-NOW: register each receiver individually as a named peer, or use the broadcast MAC address FF:FF:FF:FF:FF:FF to reach all nearby ESP-NOW devices at once. Both approaches are covered below.

    Option A – Register Each Receiver as a Named Peer

    Registering each receiver individually gives you per-device delivery confirmation. You know exactly which boards received the message and which did not. This is the right approach when the receivers are known, fixed devices, for example, three sensor nodes in a permanent installation.

    Each peer is registered separately in setup() using its own MAC address. The same struct is then sent to each peer in sequence inside loop().

    #include <esp_now.h>
    #include <WiFi.h>
    
    /* ===== Receiver MAC Addresses ===== */
    uint8_t receiver1[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; // Replace with Receiver 1 MAC
    uint8_t receiver2[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; // Replace with Receiver 2 MAC
    uint8_t receiver3[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; // Replace with Receiver 3 MAC
    
    /* ===== Data Structure ===== */
    typedef struct {
      int   counter;
      float temperature;
      bool  ledState;
    } MessageData;
    
    MessageData myData;
    
    void registerPeer(uint8_t *mac) {
      esp_now_peer_info_t peerInfo = {};
      memcpy(peerInfo.peer_addr, mac, 6);
      peerInfo.channel = 0;
      peerInfo.encrypt = false;
      if (esp_now_add_peer(&peerInfo) != ESP_OK) {
        Serial.println("Failed to register peer");
      }
    }
    
    void onDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
      Serial.print("Delivery to ");
      for (int i = 0; i < 6; i++) {
        Serial.printf("%02X", mac_addr[i]);
        if (i < 5) Serial.print(":");
      }
      Serial.print(" - ");
      Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Success" : "Failed");
    }
    
    void setup() {
      Serial.begin(115200);
      WiFi.mode(WIFI_STA);
    
      if (esp_now_init() != ESP_OK) {
        Serial.println("ESP-NOW init failed");
        return;
      }
    
      esp_now_register_send_cb(onDataSent);
    
      // Register all three receivers
      registerPeer(receiver1);
      registerPeer(receiver2);
      registerPeer(receiver3);
    }
    
    void loop() {
      myData.counter++;
      myData.temperature = 24.6;
      myData.ledState = true;
    
      // Send to each receiver individually
      esp_now_send(receiver1, (uint8_t *) &myData, sizeof(myData));
      esp_now_send(receiver2, (uint8_t *) &myData, sizeof(myData));
      esp_now_send(receiver3, (uint8_t *) &myData, sizeof(myData));
    
      Serial.print("Broadcast #");
      Serial.println(myData.counter);
    
      delay(2000);
    }

    The registerPeer() helper function is introduced here to avoid repeating the same peer registration block three times. Each call registers one receiver. The onDataSent callback now prints the MAC address of the specific peer it is reporting on, so you can see the delivery status for each receiver individually.

    ESP-NOW supports up to 20 registered peers per board. For most projects this is more than enough. If you need to reach more than 20 devices, use the broadcast MAC address described in Option B, though you will lose per-device delivery confirmation.
    What "Success" means here:
    With named peers, "Success" is triggered by a hardware-level acknowledgement sent back automatically by the receiving board's Wi-Fi radio. Unlike broadcast, this confirmation is meaningful, if you get "Success", the packet reached that specific board at the radio layer. It does not confirm that the receiver's onDataReceived callback ran or that the application processed the data, but in normal conditions this distinction rarely matters in practice.

    Option B – Broadcast MAC Address

    Using the broadcast MAC address FF:FF:FF:FF:FF:FF sends the message to every ESP-NOW-capable device within radio range, without needing to know their individual MAC addresses in advance. This is useful during development, for discovery scenarios, or when the receiver pool changes dynamically.

    The broadcast address is registered as a single peer. One esp_now_send() call then reaches all listening devices simultaneously.

    #include <esp_now.h>
    #include <WiFi.h>
    
    /* ===== Broadcast MAC Address ===== */
    uint8_t broadcastAddress[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
    
    typedef struct {
      int   counter;
      float temperature;
      bool  ledState;
    } MessageData;
    
    MessageData myData;
    
    void onDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
      Serial.print("Broadcast status: ");
      Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Success" : "Failed");
    }
    
    void setup() {
      Serial.begin(115200);
      WiFi.mode(WIFI_STA);
    
      if (esp_now_init() != ESP_OK) {
        Serial.println("ESP-NOW init failed");
        return;
      }
    
      esp_now_register_send_cb(onDataSent);
    
      // Register the broadcast address as a peer
      esp_now_peer_info_t peerInfo = {};
      memcpy(peerInfo.peer_addr, broadcastAddress, 6);
      peerInfo.channel = 0;
      peerInfo.encrypt = false;
      esp_now_add_peer(&peerInfo);
    }
    
    void loop() {
      myData.counter++;
      myData.temperature = 24.6;
      myData.ledState = true;
    
      // Single send reaches all nearby ESP-NOW devices
      esp_now_send(broadcastAddress, (uint8_t *) &myData, sizeof(myData));
    
      Serial.print("Broadcast #");
      Serial.println(myData.counter);
    
      delay(2000);
    }

    Any board running the receiver code from Part 1 will receive this broadcast without any changes. The receiver does not need to know the sender's MAC address and does not need to register any peers, it simply listens for incoming ESP-NOW packets on its channel.

    Named Peers vs Broadcast: Which to Use

    Named Peers Broadcast Address
    Per-device delivery confirmation Yes No - reports a single status only
    Receivers must be known in advance Yes No
    Maximum receivers 20 (ESP-NOW peer limit) Unlimited
    Best for Fixed installations, confirmed delivery required Dynamic discovery, development testing
    ESP-NOW Broadcast Demo
    The broadcast delivery callback reports Success as long as the transmission was sent, it does not confirm that any device actually received it. If you need to know whether a specific receiver got the message, use named peers instead.

    Common Problems and Fixes

    Most ESP-NOW issues come down to a small set of recurring mistakes. The problems below are drawn from the most common failure points, if your boards are not communicating, work through this list before looking elsewhere.

    ESP-NOW Init Failed

    If the Serial Monitor prints ESP-NOW init failed, the Wi-Fi radio was not initialised before esp_now_init() was called. ESP-NOW requires the radio to be active in Station mode first.

    Check that this line appears in setup() before the esp_now_init() call:

    WiFi.mode(WIFI_STA);

    If WiFi.mode() is called after esp_now_init(), or is missing entirely, initialisation will fail.

    Delivery Status Always Shows Failed

    A consistent Failed result in the onDataSent callback almost always means the MAC address in the sender's peerAddress array does not match the receiver's actual MAC address.

    Check the following:

    • You ran the MAC address sketch on the receiver board specifically, not the sender
    • Each byte pair is entered in the correct order
    • There are no typos, 0x8E and 0x8F look similar in the Serial Monitor
    • The receiver board is powered on and running its sketch
    If you have multiple ESP32 boards of the same model, it is easy to accidentally read the MAC address from the wrong board. Label each board with a sticker before running the MAC address sketch to avoid this.

    Delivery Shows Success but Receiver Prints Nothing

    This is the most confusing failure mode because the sender reports everything working correctly. It means the transmission completed at the radio level, but the receiving board is not running a sketch that handles ESP-NOW packets, or it is running the wrong sketch entirely.

    Check that:

    • The receiver board has the correct sketch uploaded and is running, not just powered on from a previous upload
    • esp_now_register_recv_cb(onDataReceived) is present in the receiver's setup()
    • The receiver's Serial Monitor is open and set to 115200 baud
    • In Part 2, you have not accidentally entered your own MAC address instead of the other board's

    Received Data is Garbage or Incorrect Values

    If the receiver is printing values that make no sense, a counter of 1,342,177,280 instead of 1, or a temperature of 0.00 when it should be 24.6, the struct definition on the receiver does not match the struct definition on the sender.

    Both boards must define the struct with identical field names, types, and order. Even changing a single field type, for example, declaring counter as long on one board and int on the other, will shift all subsequent fields in memory and produce incorrect values.

    // Sender defines this:
    typedef struct {
      int   counter;     // 4 bytes
      float temperature; // 4 bytes
      bool  ledState;    // 1 byte
    } MessageData;
    
    // Receiver must define EXACTLY this - same types, same order
    typedef struct {
      int   counter;
      float temperature;
      bool  ledState;
    } MessageData;

    Communication Stops After One Board Resets

    If one board resets or loses power, the other board's peer registration remains intact and transmissions will resume automatically once the reset board is back online and running its sketch. No re-pairing is needed.

    However, if you re-upload a sketch to a board while the other is transmitting, the board being uploaded will miss messages during the upload window. This is expected, once the upload completes and the sketch restarts, communication resumes normally.

    Boards Cannot Communicate Across Different Wi-Fi Channels

    ESP-NOW operates on a specific Wi-Fi channel. If one board is also connected to a Wi-Fi router, it locks to that router's channel. A board that is not connected to any router defaults to channel 1.

    If you are mixing ESP-NOW with a Wi-Fi connection on one board, and that board cannot reach a board that is not on any network, a channel mismatch is the likely cause. Both boards must be on the same channel for ESP-NOW to work.

    You can force a specific channel by adding the following line before esp_now_init():

    esp_wifi_set_channel(1, WIFI_SECOND_CHAN_NONE);

    Replace 1 with the channel number your router uses if you are combining ESP-NOW with a Wi-Fi connection. This is an advanced use case not covered in this guide, but the channel requirement is worth understanding now to avoid confusion later.

    Failed to Add Peer

    If Failed to add peer appears in the Serial Monitor, the most common causes are:

    • The same MAC address has already been added as a peer, each MAC address can only be registered once
    • The peer limit of 20 has been reached
    • esp_now_init() was not called successfully before esp_now_add_peer()

    In the one-to-many example, calling registerPeer() twice with the same MAC address will produce this error on the second call. Ensure each MAC address in your arrays is unique.

    ESP-NOW vs MQTT: Choosing the Right Protocol

    After working through this guide and the MQTT guide in this pathway, you now have two capable wireless communication tools available. The right choice depends entirely on what your project needs to do.

    ESP-NOW is the better choice when your devices need to communicate directly with each other and there is no network infrastructure available, or when you specifically want to avoid depending on one. A remote sensor node sending readings to a central ESP32 in a shed with no router, a wireless button panel controlling lights without a home automation hub, or a fleet of battery-powered devices that need to conserve power by transmitting in short bursts and sleeping the rest of the time, these are all natural fits for ESP-NOW.

    MQTT becomes the better choice the moment you need data to leave the local device cluster. If a dashboard, a phone notification, remote access from outside the home, or integration with a cloud service is part of the requirement, MQTT is the right tool. Its unlimited payload size also makes it the only viable option when you need to send more than 250 bytes per message.

    They are not mutually exclusive.
    A common and practical architecture combines both: multiple ESP32 sensor nodes communicate with a central ESP32 using ESP-NOW, and that central board forwards aggregated data to an MQTT broker over Wi-Fi. The sensor nodes stay simple and low-power, while the central board handles the network complexity. This pattern avoids giving every sensor node a Wi-Fi connection while still making the data accessible remotely.

    Conclusion

    In this guide you learned how ESP-NOW works at the protocol level, how to retrieve the MAC address of each board, and how to implement all three core communication architectures, one-way, two-way, and one-to-many broadcasting, with full working code.

    You also learned how to use structs as the standard data format for ESP-NOW transmissions, how delivery callbacks work on both the sender and receiver side, and how to diagnose the most common problems that prevent boards from communicating.

    ESP-NOW is one of the most practically useful features built into the ESP32. The ability to send data between boards instantly, without any network infrastructure, opens up a range of projects that would be significantly more complex using Wi-Fi alone.

    ESP32 IoT Systems Pathway

    Leave a comment