01Introduction
Most ESP32 tutorials reach for Wi-Fi by default. That works, but it's overkill for the common case: a handful of devices that just need to send short messages to each other on the same workbench. The router becomes a third wheel — one more thing that can fail, one more handshake, one more 200 ms.
ESP-NOW is Espressif's answer: a thin, connectionless protocol layered directly on top of the 2.4 GHz radio. Two boards exchange data by MAC address, with no AP and no IP stack. You get latency in the ~1 ms range, packets up to 250 bytes, and optional encryption per peer.
By the end of this tutorial you'll have two boards: one reading a temperature sensor, the other displaying the value on an OLED. They'll talk only to each other, with no router involved.
You'll need: 2× ESP32 dev boards, a BME280 sensor, a 0.96" SSD1306 OLED, and the Arduino IDE (or PlatformIO). The full bill of materials is on the right.
02How ESP-NOW works
Four things happen behind every ESP-NOW exchange. Step through them — the diagrams are deliberately slow so you can follow each one.
1 · Initialize the Wi-Fi radio in station mode
ESP-NOW reuses the Wi-Fi peripheral but skips association. Both boards call WiFi.mode(WIFI_STA) then esp_now_init() — no SSID, no password, just the radio coming online.
That's the whole loop. The interesting bits — encryption, broadcast mode, multi-peer mesh — all build on these four steps.
03vs Wi-Fi & Bluetooth
Pick the right radio for the job. The table below shows where each shines — ESP-NOW is built for low-latency device-to-device messaging on a small fleet.
| Aspect | ESP-NOW | Wi-Fi | BLE |
|---|---|---|---|
| Round-trip latency | ~1 ms | 30–80 ms | 7–30 ms |
| Setup complexity | Trivial | Router, IP stack | GATT services |
| Max payload | 250 B | ~1.5 KB (MTU) | 20–512 B |
| Range (typical) | ~200 m line-of-sight | ~50 m indoor | ~10 m |
| Peers | 20 (encrypted) / 1000+ (unencrypted broadcast) | 1 AP | ~8 active |
| Power draw (avg, beacon) | Very low | High | Very low |
| Internet access | No | Yes | Via gateway |
Heads up: ESP-NOW and Wi-Fi share the radio. If you run both, lock them to the same channel — otherwise the radio thrashes and you'll lose packets.
04Sender code
Reads the BME280 every two seconds and pushes a small struct to the receiver's MAC. We use esp_now_send() — fire-and-forget — and rely on the send callback to log success.
// sender.ino — broadcasts BME280 readings to one peer #include <WiFi.h> #include <esp_now.h> #include <Adafruit_BME280.h> uint8_t peerMac[] = { 0x3C, 0x71, 0xBF, 0x08, 0x2B, 0x91 }; struct Sample { float tempC; float humidity; float pressure; uint32_t seq; }; Adafruit_BME280 bme; Sample sample; // Called after each send — gives you delivery status void onSent(const uint8_t *mac, esp_now_send_status_t status) { Serial.printf("seq=%u %s\n", sample.seq, status == ESP_NOW_SEND_SUCCESS ? "OK" : "FAIL"); } void setup() { Serial.begin(115200); bme.begin(0x76); WiFi.mode(WIFI_STA); esp_now_init(); esp_now_register_send_cb(onSent); esp_now_peer_info_t peer = {}; memcpy(peer.peer_addr, peerMac, 6); peer.channel = 0; // follow current channel peer.encrypt = false; esp_now_add_peer(&peer); } void loop() { sample.tempC = bme.readTemperature(); sample.humidity = bme.readHumidity(); sample.pressure = bme.readPressure() / 100.0f; sample.seq++; esp_now_send(peerMac, (uint8_t*)&sample, sizeof(sample)); delay(2000); }
05Receiver code
Mirror image: register a recv callback, decode the same struct, render to the OLED. Note we never call esp_now_add_peer() here — the receiver doesn't need to know about the sender in advance.
// receiver.ino — prints incoming Samples to an SSD1306 #include <WiFi.h> #include <esp_now.h> #include <Adafruit_SSD1306.h> Adafruit_SSD1306 oled(128, 64, &Wire); struct Sample { float tempC; float humidity; float pressure; uint32_t seq; }; void onRecv(const uint8_t *mac, const uint8_t *data, int len) { if (len != sizeof(Sample)) return; Sample s; memcpy(&s, data, len); oled.clearDisplay(); oled.setCursor(0, 0); oled.printf("%.1f °C\n%.0f %%RH\n%.0f hPa\nseq %u", s.tempC, s.humidity, s.pressure, s.seq); oled.display(); } void setup() { oled.begin(SSD1306_SWITCHCAPVCC, 0x3C); WiFi.mode(WIFI_STA); esp_now_init(); esp_now_register_recv_cb(onRecv); } void loop() {} // everything happens in the callback
The highlighted block is the entire receive path — eight lines from radio to screen.
06Wiring diagram
Both boards run from USB. The sender adds a BME280 on I²C; the receiver adds an SSD1306 OLED, also on I²C. Shared pins: SDA = GPIO 21, SCL = GPIO 22.
07Step-by-step build
Find each board's MAC address
Flash a 3-line sketch on each board that calls WiFi.macAddress() and prints it. Copy the receiver's MAC into peerMac[] in the sender sketch.
Wire the BME280 to the sender
VCC → 3V3, GND → GND, SDA → GPIO 21, SCL → GPIO 22. Power via USB. Confirm the I²C address with an i2c-scanner sketch — usually 0x76 or 0x77.
Wire the OLED to the receiver
Same pins as the sensor. Most SSD1306 modules answer to 0x3C. If your screen is blank, swap to 0x3D.
Install libraries
From Library Manager: Adafruit BME280, Adafruit SSD1306, Adafruit GFX. ESP-NOW is part of the ESP32 core — no extra install.
Flash sender and receiver
Pick board ESP32 Dev Module, set partition to Huge APP (3MB), upload sender.ino to board A and receiver.ino to board B.
Open Serial Monitor
At 115200 baud you should see seq=1 OK, seq=2 OK… and the OLED across the bench should update every two seconds. You just built a wireless sensor network with zero infrastructure.
08Troubleshooting
Sender prints FAIL for every packet
peerMac[] doesn't match the receiver, or the two boards are on different Wi-Fi channels. Force both to channel 1: esp_wifi_set_channel(1, WIFI_SECOND_CHAN_NONE); after esp_now_init().Receiver callback never fires
WIFI_STA mode, not WIFI_AP_STA, before calling esp_now_init(). Also confirm the sender is broadcasting to the right MAC — print it with WiFi.macAddress() on the receiver and compare.Packets work near the desk but drop across the room
Receiver garbles after I added Wi-Fi
Struct on receiver has garbage values
struct layout, including padding. Add __attribute__((packed)) if you're mixing different compilers or sending to a non-ESP32 peer.09What's next
You've got the two-board basic case. From here, three branches are worth exploring:
- Broadcast mode — send to
FF:FF:FF:FF:FF:FFand reach every ESP-NOW peer on the channel — the basis of cheap "find me" networks. - Encrypted peers — set
peer.encrypt = trueand share a 16-byte LMK between sender and receiver for AES-128 protection. - Hybrid gateway — pair an ESP-NOW receiver with Wi-Fi/MQTT on the same board to bridge a local mesh to the internet. We cover this in "ESP-NOW + MQTT Gateway".