Introduction
Most ESP32 wireless projects begin with a Wi-Fi router, and that quietly adds a long list of dependencies: credentials in your code, a network that has to be up, an IP address that might change, and a few hundred milliseconds of variable delay. For a sensor that just needs to tell another board "something moved", that is a lot of machinery.
ESP-NOW removes it. It is a connectionless protocol from Espressif that lets ESP32 boards send small packets straight to each other, addressed by MAC address. There is no access point, no password, no DHCP, no TCP/IP stack and no internet. A board can power up and transmit within moments, which is also why ESP-NOW is popular for battery-powered nodes.
This tutorial builds a complete, working link from the ground up. A sender ESP32 reads an IR obstacle sensor and an MQ135 air-quality sensor and transmits a small packet every 300 ms. A receiver ESP32 shows the data on an OLED, sounds a siren when something is wrong, and tells you when the link goes quiet. The sensors are only the demo. The real goal is that you understand how ESP-NOW works, so you can reuse the pattern in anything else.
Along the way you will learn how to:
- find a board's MAC address and use it to pair two boards;
- design a packet structure that survives different compilers and boards;
- write callbacks that are safe, because they run inside the Wi-Fi task;
- detect lost packets and a dead link, which ESP-NOW does not do for you;
- write code that compiles on both the older 2.x and the current 3.x Arduino-ESP32 cores;
- add encryption, and understand what it does and doesn't protect.
What Is ESP-NOW?
ESP-NOW is a connectionless Wi-Fi communication protocol defined by Espressif. Your data is wrapped in a vendor-specific action frame, a standard 802.11 management-frame type that lets manufacturers define their own payloads, and is sent from one device straight to another. It uses the same 2.4 GHz radio and the same Wi-Fi driver as normal Wi-Fi, but it skips association with an access point and skips the TCP/IP stack entirely. Fewer layers means less setup time, less overhead and less power per message.
| Characteristic | ESP-NOW |
|---|---|
| Addressing | The 6-byte MAC address of the receiving board |
| Payload per packet | Up to 250 bytes (v1.0). Newer ESP-IDF 5.4+ builds also support ESP-NOW v2.0 with up to 1,470 bytes, between devices that both support it |
| Peers | Up to 20 registered peers, of which at most 6 can use encryption (default configuration) |
| Encryption | Optional, per peer, using CCMP with a 16-byte key. Not available for broadcast |
| Topology | One-to-one, one-to-many, many-to-one, or broadcast |
| Connection | None. There is no handshake and no session |
| Channel | Both boards must be on the same Wi-Fi channel |
| Delivery feedback | A send callback reports whether the receiving radio acknowledged a unicast frame |
Two properties deserve emphasis because they shape how you design anything on top of ESP-NOW. First, there is no connection, so neither side automatically knows whether the other is alive. If the receiver loses power, the sender carries on transmitting into the void and nothing in the protocol complains. We will add our own link-loss detection for exactly that reason. Second, callbacks run in the Wi-Fi task, a high-priority task inside the radio driver. If you spend 300 ms in a callback, you are blocking the radio itself.
How it compares
| ESP-NOW | Wi-Fi + router | Bluetooth LE | LoRa | |
|---|---|---|---|---|
| Needs infrastructure | No | Yes (router) | No | Gateway for networks |
| Setup effort | Low | Medium | Medium | Medium |
| Typical use | Sensors, remotes, board-to-board | Internet, dashboards, cloud | Phones, wearables | Very long range, tiny data |
| Range | Short to medium | Router coverage | Short | Long |
| Works with a phone | No | Yes | Yes | Via gateway |
ESP-NOW is the right tool when both ends are ESP32 or ESP8266-family boards, the data is small, and you want fast, simple, router-free communication. It is the wrong tool when you need to reach a phone directly or the internet.
The Four Things ESP-NOW Needs
Before writing any code, here are the four building blocks every ESP-NOW program uses. If you remember nothing else from this article, remember these.
1. The Wi-Fi radio on, but not connected. Both boards call WiFi.mode(WIFI_STA). That powers up the radio and driver. They never join a network. ESP-NOW data can only be transmitted after Wi-Fi has started, so this always comes first.
2. ESP-NOW initialisation. esp_now_init() starts the protocol engine. Check its return value, because if it fails, every later call fails in confusing ways.
3. A registered peer (sender side). The sender registers the receiver with esp_now_add_peer(), giving the receiver's MAC address, a channel and an encryption flag. A receiver that only listens does not need to register anyone.
4. Callbacks. The sender registers a send callback that fires after each transmission with a success or failure status. The receiver registers a receive callback that fires whenever a packet arrives, passing the raw bytes and information about the sender.
The data itself is a plain byte array. The cleanest way to handle it is a C struct that is defined identically on both boards, copied into bytes when sending and copied back out when receiving.
Components
8 itemsNeed these parts? Get this build sheet by email
Parts list with buy links, plus new builds as they're published. By subscribing you agree to our Privacy Policy. Unsubscribe anytime.
The sensors and display exist to give the link something meaningful to carry. Swap in any sensors you own, and the communication code stays the same.
Circuit Connections
Sender node
| Device | Pin | ESP32 GPIO |
|---|---|---|
| IR obstacle sensor | OUT | GPIO 32 |
| MQ135 module (via divider) | AO | GPIO 35 |
Receiver node
| Device | Pin | ESP32 GPIO |
|---|---|---|
| SSD1306 OLED | SDA | GPIO 19 |
| SSD1306 OLED | SCL | GPIO 21 |
| Buzzer / speaker | Signal | GPIO 18 |
GPIO 35 on the DevKit V1 is input-only and sits on ADC1, which makes it a good analog input, and ADC1 keeps working while Wi-Fi is active (the ADC2 pins do not). The receiver's I2C bus is started manually with Wire.begin(19, 21), so you are not locked to the default pins.
⚠️ Warning
The MQ135 module needs about 5 V for its heater, and its AO output can swing above the ESP32's 3.3 V ADC limit. Feeding that straight into GPIO 35 can damage the pin. Put two 10 kΩ resistors in series between AO and ground and connect GPIO 35 to the middle point, which halves the voltage. The readings will scale down accordingly, so judge your alert level from your own measurements. The MQ135 also needs several minutes to warm up before its values settle, and a brand-new sensor benefits from a long first burn-in. Power the IR module from 3.3 V so its output never exceeds what the ESP32 can tolerate.
Step 1: Read the Receiver's MAC Address
A unicast ESP-NOW link is addressed by MAC. The sender needs the receiver's exact 6-byte address. Flash this to the receiver board and open Serial Monitor at 115200 baud. It prints the address once a second, so you will not miss it if you open the monitor late.
You will see something like A8:42:E3:BA:C2:D8. Write it on a sticker and put it on the board, because you will want it again. In the sender sketch it becomes a byte array: {0xA8, 0x42, 0xE3, 0xBA, 0xC2, 0xD8}. Each board has its own MAC, so use the address of your receiver, not the example.
Step 2: Define the Shared Packet
Both boards must agree on the exact byte layout of the packet. Here is the structure used in this project. Copy it into both sketches unchanged, or put it in a small message.h file and include it from both.
Three choices here are deliberate, and each prevents a real bug:
- Fixed-width types (
uint8_t,uint16_t,uint32_t) rather thanboolandint. They are the same size on every board and compiler, so the layout cannot silently change if you later mix ESP32 variants or code from other sources. packed. Without it, the compiler may insert invisible padding bytes between fields so each one starts at a convenient address. Padding is harmless within one program but changessizeof, which would make two builds disagree on the packet length.- A sequence number. ESP-NOW has no concept of "the packet before this one". With a counter in every packet, the receiver can detect gaps and report real packet loss instead of guessing.
If the struct differs between boards, even by one field, memcpy will misalign every value and you will read garbage. The receiver also checks that the incoming length equals sizeof(Message) and ignores anything else, which cheaply filters out foreign packets.
Step 3: The Sender
The sender starts the radio, forces a known channel, initialises ESP-NOW, registers the receiver as a peer, then reads both sensors every 300 ms and sends one packet. It uses millis() for timing so loop() never stalls.
A note on callback signatures
Espressif changed the callback prototypes between ESP-IDF versions, and the Arduino-ESP32 core follows it. The receive callback gained an esp_now_recv_info_t argument in ESP-IDF 5.1 (core 3.x), and the send callback changed again in ESP-IDF 5.5 (core 3.3 and later), where it receives an esp_now_send_info_t instead of the MAC address. If you copy code written for one core into another, the compiler complains about an invalid conversion. The sketches below use ESP_IDF_VERSION checks so the same file compiles on the older 2.x cores and on current 3.x cores.
Reading the sender's output
esp_now_send() returning ESP_OK only means the packet was queued. Whether it actually arrived is reported later by onDataSent, which for a unicast peer reflects whether the receiving radio acknowledged the frame at the link layer. That is why the sketch counts successes and failures separately. A steady stream of failures means the receiver is off, out of range, or on a different channel.
Step 4: The Receiver
The receiver's callback does as little as possible: check the length, copy the packet under a lock, set a flag. Everything slow happens in loop(): updating the OLED, counting lost packets, and running the siren.
Two details are worth understanding. The copy is wrapped in a critical section because the callback runs on the Wi-Fi task while loop() runs on another task, possibly on the other core. Even a seven-byte struct could otherwise be read half-updated. And the receiver tracks the time of the last packet, so it can show LINK LOST when nothing has arrived for two seconds, which is the monitoring ESP-NOW does not give you for free.
💡 Tip
If you see an error such as "invalid conversion from void (*)(const uint8_t*, ...)", the callback prototype does not match your core. The ESP_IDF_VERSION checks above handle ESP-IDF 4.4 (core 2.x), 5.1 to 5.4 (core 3.0 to 3.2) and 5.5+ (core 3.3 and later). If a future core changes the prototype again, copy the signature from the compiler error. The two keys to remember: the receive callback takes (info, data, len) from IDF 5.1 on, and the send callback takes an info struct from IDF 5.5 on.
What Happens on Every Cycle
Once both boards are powered, this sequence repeats every 300 ms:
- The sender reads its sensors and fills the packet, including the next sequence number.
esp_now_send()queues the packet, addressed to the receiver's MAC.- The radio transmits on the fixed channel. No router is involved.
onDataSentfires on the sender with the link-layer acknowledgement result.onReceivefires on the receiver, which copies the bytes and sets a flag.loop()picks up the packet, counts any gap in the sequence, redraws the OLED and decides whether to sound the siren.
The receiver never polls the sender. It reacts to arrivals and watches the clock for silence. That event-driven shape is what makes ESP-NOW feel instant, and the lost-packet counter and link timeout are what make it trustworthy.
Testing the Link
Work through these checks in order. Each one isolates a single failure.
- Flash the receiver first and note its MAC. It prints it on boot, and the OLED should say WAITING.
- Put the MAC into the sender sketch, flash the sender, and open its Serial Monitor. You should see lines counting up and
okincreasing. - On the receiver the OLED should change to SAFE and the
rxcounter should climb about three times a second. - Pass your hand in front of the IR sensor. The OLED should flip to ALERT and the siren should start, then stop when you remove your hand.
- Unplug the sender. After about two seconds the receiver should show LINK LOST. Plug it back in and it should recover on its own.
- Walk the sender away until
loststarts rising. That tells you roughly where your usable range ends in your own building.
Step 6 is the most valuable. Published range figures are best-case numbers measured in the open, and your walls, metal enclosures and antenna orientation decide what you actually get.
Channels, Wi-Fi and Why Links Break
The radio can only listen on one channel at a time, and ESP-NOW frames are sent on whichever channel the radio is currently using. For two boards to talk, they must be on the same one. That is why the sketches pin both to channel 1 explicitly instead of trusting defaults.
The catch comes when one of your boards also joins a Wi-Fi router, for example to publish data online. A station that connects to a router is forced onto the router's channel, and a receiver that jumps channels will stop hearing a sender that did not. Two ways to handle it:
- Make the board that talks to the router the receiver, read its channel after it connects with
WiFi.channel(), and set the sender's channel to match. - Or keep ESP-NOW boards off the router entirely and let one gateway board bridge between the two worlds.
Either way, the symptom of a channel mismatch is always the same: the sender's callback reports failures while the receiver shows nothing.
Securing the Link
By default, ESP-NOW frames are unencrypted and unauthenticated. Anyone within radio range with the right tools can read them or send forged packets that look genuine, and an attacker can also replay a captured packet. For a hobby sensor that is often acceptable. For anything that guards something, add encryption.
ESP-NOW supports CCMP encryption per peer, using a 16-byte local master key (LMK). Both boards must register each other with the same key, so the receiver also needs the sender's MAC address. A primary master key (PMK) protects the LMKs and can be set with esp_now_set_pmk(); if you do not set one, a default is used.
Be realistic about what this buys you. Encryption stops casual sniffing and forged packets from boards that do not have the key. It does not protect against someone who can read the keys out of your firmware (they sit in flash unless you enable flash encryption), and broadcast frames cannot be encrypted at all. Combine it with the sequence number and reject packets whose counter does not move forward if replays matter to you.
Common ESP-NOW Mistakes
| Mistake | What you see | Fix |
|---|---|---|
| Different channels on the two boards | Sender callback always fails, receiver silent | Pin both with esp_wifi_set_channel() |
| Struct differs between sketches | Receiver shows nonsense values | Share one definition, use fixed-width packed types |
Slow work inside onReceive | Dropped packets, watchdog resets | Copy and set a flag, work in loop() |
Treating ESP_OK from esp_now_send as delivery | Packets "sent" but never arrive | Check the send callback status |
Sending before esp_now_add_peer | Send errors | Register the peer first |
Not checking esp_now_init() | Cascade of unrelated errors | Check the return value and stop |
| Wrong MAC address | Sender reports failure | Re-read the receiver's MAC, check each byte |
| Callback prototype from a different core | Compile error | Use the version checks shown above |
| Sending faster than the radio can handle | Send callback out of order or failing | Space packets out; a few milliseconds minimum |
Where to Take This Next
The pattern you built scales naturally:
- Broadcast. Use the address
FF:FF:FF:FF:FF:FFto send to every ESP-NOW device in range. Broadcast frames are not acknowledged, so a "success" does not mean anyone received it, and they cannot be encrypted. - Many to one. Several sensor nodes can send to one receiver. Identify each by the sender address supplied to the callback, or add a node ID field to the struct.
- Two-way traffic. Both boards register each other and implement both callbacks. Use it for acknowledgements, commands or configuration.
- Battery nodes. A sender can wake from deep sleep, transmit one packet and sleep again, which is the main reason ESP-NOW suits battery sensors. Our ESP32 Deep Sleep guide covers the wake, work, sleep pattern.
- Bigger payloads. On ESP-IDF 5.4+ builds, ESP-NOW v2.0 allows packets far larger than 250 bytes between devices that both support it. Check what your core version exposes before relying on it.
Libraries Required
| Library | Source |
|---|---|
| Adafruit SSD1306 | Arduino Library Manager |
| Adafruit GFX Library | Arduino Library Manager |
| WiFi, esp_now, esp_wifi | Built into the ESP32 Arduino core |
Frequently Asked Questions
Do ESP-NOW boards need to be on the same Wi-Fi network? No. They never join one. They only need to be on the same radio channel.
How far can ESP-NOW reach? Open-air figures of a hundred metres or more are often reported with a good antenna and stable power, but walls, metal and interference reduce that sharply. Measure in your own space using the lost-packet counter.
Can I use ESP-NOW and Wi-Fi at the same time? Yes, with care. The single radio is shared, so ESP-NOW frames use the channel the Wi-Fi connection is on. Plan the channel as described above.
Can ESP32 talk to ESP8266 with ESP-NOW? Yes, they interoperate, but the ESP8266 uses different function names and roles. Check its API before copying code across.
Why does my receiver miss packets when the siren plays? In the version in this article it should not, because the callback only copies the packet and the siren is non-blocking. If you add slow work such as delay() or long display updates to loop(), the link still receives packets, but your reaction to them is delayed.
Conclusion
ESP-NOW is one of the most useful and most overlooked features of the ESP32. It delivers direct, fast, router-free communication, and the whole pattern fits on one page: share a packet definition, pick a channel, register a peer, send on one side, and copy-and-flag on the other. What turns a demo into something dependable is the engineering around that core: a sequence number to expose loss, a timeout to expose silence, short callbacks, and optional encryption.
Once this two-board link is solid, you have the foundation for multi-node sensor networks, wireless remotes and battery-powered edge devices that need no infrastructure at all.
Built with ESP32, ESP-NOW and C++. Project by Manish Pal, B.Tech Electronics & Communication Engineering.

