Phase 4: Connectivity 13 min read

ESP-NOW — Low-Latency Peer-to-Peer Wireless Between ESP32 Boards

Use ESP-NOW to send data between ESP32 boards without a Wi-Fi router. Covers point-to-point, broadcast, and multi-peer communication with send/receive callbacks and error handling.

Updated June 18, 2026

Why ESP-NOW?

ESP-NOW operates at the 802.11 MAC layer — there is no TCP handshake, no IP address, no DHCP, no router. One ESP32 sends a 250-byte packet directly to another ESP32’s MAC address and it arrives in under 2 milliseconds. For remote sensors, wireless remotes, and mesh networks where a router is impractical, ESP-NOW is the right tool.

Comparison: ESP-NOW vs Wi-Fi MQTT vs BLE

Feature ESP-NOW Wi-Fi + MQTT BLE
Latency < 2 ms 50–300 ms 10–50 ms
Router needed No Yes No
Max payload 250 bytes 256 MB 512 bytes (MTU)
Peer limit 20 encrypted Unlimited 9 (NimBLE)
Wake from sleep ~50 ms ~2000 ms ~500 ms

Point-to-Point Sender

espnow_sender.ino
#include <WiFi.h>
#include <esp_now.h>

// Replace with the MAC address printed by the RECEIVER sketch
uint8_t receiverMAC[] = {0x24, 0x6F, 0x28, 0xAB, 0xCD, 0xEF};

typedef struct {
  float    temperature;
  float    humidity;
  uint32_t counter;
} SensorPacket;

SensorPacket packet;

void onDataSent(const uint8_t* mac, esp_now_send_status_t status) {
  Serial.printf("Send to %02X:%02X:%02X:%02X:%02X:%02X — %s\n",
    mac[0], mac[1], mac[2], mac[3], mac[4], mac[5],
    status == ESP_NOW_SEND_SUCCESS ? "OK" : "FAIL");
}

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.disconnect();

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW init failed");
    return;
  }
  esp_now_register_send_cb(onDataSent);

  esp_now_peer_info_t peer = {};
  memcpy(peer.peer_addr, receiverMAC, 6);
  peer.channel = 0;     // 0 = current channel
  peer.encrypt = false;
  esp_now_add_peer(&peer);

  Serial.println("Sender ready. My MAC: " + WiFi.macAddress());
}

void loop() {
  packet.temperature = 22.5 + (random(-20, 20) / 10.0);
  packet.humidity    = 55.0 + (random(-50, 50) / 10.0);
  packet.counter++;

  esp_err_t result = esp_now_send(receiverMAC,
    (uint8_t*)&packet, sizeof(packet));

  Serial.printf("Sent #%lu: %.1f°C %.1f%% — %s\n",
    packet.counter, packet.temperature, packet.humidity,
    result == ESP_OK ? "queued" : "error");

  delay(2000);
}

Receiver

espnow_receiver.ino
#include <WiFi.h>
#include <esp_now.h>

typedef struct {
  float    temperature;
  float    humidity;
  uint32_t counter;
} SensorPacket;

void onDataRecv(const esp_now_recv_info_t* info,
                const uint8_t* data, int len) {
  SensorPacket* p = (SensorPacket*)data;
  Serial.printf("From %02X:%02X:%02X:%02X:%02X:%02X — ",
    info->src_addr[0], info->src_addr[1], info->src_addr[2],
    info->src_addr[3], info->src_addr[4], info->src_addr[5]);
  Serial.printf("#%lu  %.1f°C  %.1f%%\n",
    p->counter, p->temperature, p->humidity);
}

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.disconnect();

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW init failed");
    return;
  }
  esp_now_register_recv_cb(onDataRecv);

  Serial.println("Receiver ready. My MAC: " + WiFi.macAddress());
  Serial.println("Give this MAC to the sender sketch.");
}

void loop() {}

Broadcast to All Devices

espnow_broadcast.ino
uint8_t broadcastMAC[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

esp_now_peer_info_t bcastPeer = {};
memcpy(bcastPeer.peer_addr, broadcastMAC, 6);
bcastPeer.channel = 0;
bcastPeer.encrypt = false;
esp_now_add_peer(&bcastPeer);

// Send:
esp_now_send(broadcastMAC, (uint8_t*)&packet, sizeof(packet));

Deep Sleep Sender (Battery Sensor Node)

espnow_deepsleep.ino
#include <WiFi.h>
#include <esp_now.h>
#include <esp_sleep.h>

#define SLEEP_SECONDS 60

uint8_t receiverMAC[] = {0x24, 0x6F, 0x28, 0xAB, 0xCD, 0xEF};
volatile bool sent = false;

typedef struct { float temp; uint32_t bootCount; } Pkt;

RTC_DATA_ATTR uint32_t bootCount = 0;

void onSent(const uint8_t* mac, esp_now_send_status_t s) {
  Serial.printf("Sent: %s\n", s == ESP_NOW_SEND_SUCCESS ? "OK" : "FAIL");
  sent = true;
}

void setup() {
  Serial.begin(115200);
  bootCount++;

  WiFi.mode(WIFI_STA);
  WiFi.disconnect();
  esp_now_init();
  esp_now_register_send_cb(onSent);

  esp_now_peer_info_t p = {};
  memcpy(p.peer_addr, receiverMAC, 6);
  esp_now_add_peer(&p);

  Pkt pkt = { 23.5, bootCount };
  esp_now_send(receiverMAC, (uint8_t*)&pkt, sizeof(pkt));

  // Wait for send callback (max 200 ms)
  unsigned long t = millis();
  while (!sent && millis() - t < 200) delay(10);

  Serial.printf("Sleeping %d s (boot #%lu)\n", SLEEP_SECONDS, bootCount);
  esp_deep_sleep(SLEEP_SECONDS * 1000000ULL);
}

void loop() {}

Frequently Asked Questions

ESP-NOW is a proprietary Espressif protocol that lets ESP8266/ESP32 devices communicate directly at the MAC layer, bypassing TCP/IP entirely. Use it when you need ultra-low latency (< 2 ms), want to avoid a router, need to wake from deep sleep and send quickly, or want a wireless sensor network without internet.
ESP-NOW payloads are limited to 250 bytes per packet. For larger data, split into multiple packets and reassemble with a sequence counter on the receiver side.
Yes, but both must use the same Wi-Fi channel. Call WiFi.begin() first (STA mode) to get the router channel, then initialise ESP-NOW. The channel is determined by the router and both protocols share it.
Call WiFi.macAddress() after WiFi.mode(WIFI_STA) and before WiFi.begin(). Print it to Serial. Alternatively use esp_read_mac() from esp_wifi.h for the base MAC without initialising Wi-Fi.
The broadcast MAC address is {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}. Sending to this address delivers the packet to ALL ESP-NOW-listening devices in range, regardless of whether they are registered as peers.
Yes. The receiver must register each sender as a peer using esp_now_add_peer(). In practice you can register up to 20 peers. All their messages arrive at the same onDataRecv callback where you use the sender MAC to route them.
Typically 200–500 m line-of-sight outdoors with PCB antenna. With an external omnidirectional antenna (IPEX connector or U.FL on some modules) range can exceed 1 km.
Encryption is optional per-peer. Enable it with esp_now_peer_info_t.encrypt = true and supply a 16-byte Local Master Key (LMK). Without encryption, packets are transmitted in plain text.
The sender's onDataSent callback receives ESP_NOW_SEND_FAIL status. The packet is dropped — ESP-NOW does not retry or queue. Implement application-level acknowledgement if delivery confirmation is critical.
Not directly — the radio is off during deep sleep. The typical pattern is: wake, call WiFi.mode(WIFI_STA) + esp_now_init(), send one packet, wait for the onDataSent callback, then go back to deep sleep. Total awake time is 50–150 ms.

Projects to Build

Put this knowledge to work — try one of these hands-on projects.