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.

Result · radio listening, MAC address fixed
ESP32 · AESP32 · BWIFI_STA · radio on

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.

AspectESP-NOWWi-FiBLE
Round-trip latency ~1 ms 30–80 ms 7–30 ms
Setup complexityTrivialRouter, IP stackGATT services
Max payload250 B~1.5 KB (MTU)20–512 B
Range (typical)~200 m line-of-sight~50 m indoor~10 m
Peers20 (encrypted) / 1000+ (unencrypted broadcast)1 AP~8 active
Power draw (avg, beacon)Very lowHighVery low
Internet accessNoYesVia 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.inoArduino / C++
// 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.inoArduino / C++
// 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.

Wiring · 2× ESP32 · BME280 · SSD13063V3GNDSDASCL
SENDERRECEIVERESP32 · ASender3V3GNDGPIO 21 · SDAGPIO 22 · SCLBME280T · RH · PVCCGNDSDASCLESP-NOW · 2.4 GHzESP32 · BReceiver3V3GNDGPIO 21 · SDAGPIO 22 · SCL23.4°C · 51%SSD1306 OLED

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
Most likely the MAC in 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
Make sure the receiver is in 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
Onboard chip antennas have variable gain — check the orientation, and avoid placing the board flat against metal. For longer range, use a board with an external IPEX connector + 2.4 GHz whip antenna.
Receiver garbles after I added Wi-Fi
ESP-NOW shares the radio with Wi-Fi. If the Wi-Fi stack hops channels (during association, OTA, etc), ESP-NOW packets won't land. Pin the channel after every Wi-Fi event, or run them on separate cores with a short Wi-Fi cycle.
Struct on receiver has garbage values
Both sketches must use the exact same 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:FF and reach every ESP-NOW peer on the channel — the basis of cheap "find me" networks.
  • Encrypted peers — set peer.encrypt = true and 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".

Was this tutorial useful?

It took 8 hours to write and verify. A star helps the next maker find it.

Browse all tutorials