An ESP32 relay MQTT controller lets you switch lamps, pumps, fans or contactors from any app, script or dashboard that can publish a message. In this tutorial you will wire a relay module to an ESP32, subscribe to a command topic, publish the relay state as a retained message, and restore the last known state after a reboot. Everything is tested against the free public area of broker.connectmq.com, then moved to credentials for production.
How the ESP32 Relay MQTT Design Works
A relay node is the simplest useful actuator in IoT, but it is also where many projects go wrong. The usual mistake is to treat MQTT as a remote button: something publishes ON, the ESP32 switches the relay, and nobody ever knows whether the command actually arrived. A better design separates what someone wants from what the device is actually doing.
| Topic | Direction | Retained | Payload |
|---|---|---|---|
connectmq/demo/<id>/relay/1/set |
App to ESP32 | No | ON, OFF, TOGGLE |
connectmq/demo/<id>/relay/1/state |
ESP32 to apps | Yes | ON or OFF |
connectmq/demo/<id>/status |
ESP32 to apps | Yes | online or offline (Last Will) |
The command topic is not retained. If it were, every reconnect would replay an old command, and a relay that someone switched off manually could suddenly turn back on. The state topic is retained, so a dashboard that opens a minute later immediately receives the current state without waiting for the next change. The status topic uses MQTT's Last Will so subscribers learn when the node drops off the network.
If retained messages and Last Will are new to you, the overview of what an MQTT broker does explains both in a few minutes.
Hardware and Wiring
You need:
- Any ESP32 development board (DevKitC, NodeMCU-32S, WROOM or WROVER modules all work).
- A 1-, 2- or 4-channel relay module with an optocoupler input. Modules sold for 5 V with "high/low level trigger" jumpers are the most flexible.
- Jumper wires and a USB cable. For anything connected to mains, a proper enclosure and terminal blocks.
Typical wiring for a 5 V module:
| Relay module pin | ESP32 pin | Note |
|---|---|---|
| VCC | 5V (VIN) | Powers the relay coil |
| GND | GND | Common ground |
| IN1 | GPIO 26 | Control signal for channel 1 |
| IN2 | GPIO 27 | Control signal for channel 2 (optional) |
Most cheap relay boards are active-low: the relay energises when the input is pulled to GND. The sketch below has a single constant to flip that logic. Avoid GPIO 0, 2, 12 and 15 for relays, because they are strapping pins and a relay clicking during boot is both annoying and, with some loads, dangerous. GPIO 34 to 39 are input-only and will not work.
A safety note: if you switch mains voltage, the relay module must be rated for the load, the high-voltage side must be enclosed, and the work should follow local electrical codes. While you develop the firmware, switch a low-voltage LED strip or nothing at all and listen for the click.
Install the Libraries
In Arduino IDE 2.x, install the esp32 board package from Espressif via the Boards Manager and the PubSubClient library by Nick O'Leary via the Library Manager. No other libraries are required. If you have not built an MQTT sketch on the ESP32 before, the ESP32 IoT tutorial walks through board setup and your first publish in more detail.
The Complete ESP32 Relay MQTT Sketch
The sketch below supports any number of relays, handles reconnects without blocking the loop, and publishes retained state after every change.
#include <WiFi.h>
#include <PubSubClient.h>
// ---------- Configuration ----------
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 1883;
const char* DEVICE_ID = "relay-7f3a91"; // make this unique
const String BASE = String("connectmq/demo/") + DEVICE_ID;
const bool ACTIVE_LOW = true; // most relay boards
const uint8_t RELAY_PINS[] = {26, 27};
const uint8_t RELAY_COUNT = sizeof(RELAY_PINS);
bool relayState[RELAY_COUNT] = {false};
bool restoreWindow = true; // accept retained state once at boot
unsigned long connectedAt = 0;
WiFiClient net;
PubSubClient mqtt(net);
unsigned long lastAttempt = 0;
void applyRelay(uint8_t i, bool on) {
relayState[i] = on;
digitalWrite(RELAY_PINS[i], (on ^ ACTIVE_LOW) ? HIGH : LOW);
}
void publishState(uint8_t i) {
String topic = BASE + "/relay/" + String(i + 1) + "/state";
mqtt.publish(topic.c_str(), relayState[i] ? "ON" : "OFF", true);
}
void onMessage(char* topic, byte* payload, unsigned int length) {
String t(topic);
String msg;
for (unsigned int i = 0; i < length; i++) msg += (char)payload[i];
msg.trim();
msg.toUpperCase();
// Expected: <BASE>/relay/<n>/set or <BASE>/relay/<n>/state
int start = BASE.length() + 7; // skip "/relay/"
int slash = t.indexOf('/', start);
if (slash < 0) return;
int n = t.substring(start, slash).toInt();
if (n < 1 || n > RELAY_COUNT) return;
uint8_t i = n - 1;
String leaf = t.substring(slash + 1);
if (leaf == "state") {
// Retained state from before the reboot: restore it once, then ignore
if (restoreWindow) applyRelay(i, msg == "ON");
return;
}
if (leaf != "set") return;
if (msg == "ON") applyRelay(i, true);
else if (msg == "OFF") applyRelay(i, false);
else if (msg == "TOGGLE") applyRelay(i, !relayState[i]);
else return; // ignore unknown commands
publishState(i);
Serial.printf("Relay %d -> %s\n", n, relayState[i] ? "ON" : "OFF");
}
bool connectMqtt() {
String willTopic = BASE + "/status";
bool ok = mqtt.connect(DEVICE_ID, nullptr, nullptr,
willTopic.c_str(), 1, true, "offline");
if (!ok) {
Serial.printf("MQTT connect failed, state=%d\n", mqtt.state());
return false;
}
mqtt.publish(willTopic.c_str(), "online", true);
for (uint8_t i = 0; i < RELAY_COUNT; i++) {
String base = BASE + "/relay/" + String(i + 1);
mqtt.subscribe((base + "/set").c_str(), 1);
if (restoreWindow) mqtt.subscribe((base + "/state").c_str(), 1);
}
connectedAt = millis();
Serial.println("MQTT connected");
return true;
}
void setup() {
Serial.begin(115200);
for (uint8_t i = 0; i < RELAY_COUNT; i++) {
pinMode(RELAY_PINS[i], OUTPUT);
applyRelay(i, false); // safe default before Wi-Fi
}
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setCallback(onMessage);
mqtt.setKeepAlive(30);
mqtt.setSocketTimeout(5);
}
void loop() {
if (WiFi.status() != WL_CONNECTED) return;
if (!mqtt.connected()) {
if (millis() - lastAttempt > 5000) {
lastAttempt = millis();
connectMqtt();
}
return;
}
mqtt.loop();
// Close the restore window two seconds after the first connection
if (restoreWindow && millis() - connectedAt > 2000) {
restoreWindow = false;
for (uint8_t i = 0; i < RELAY_COUNT; i++) {
String s = BASE + "/relay/" + String(i + 1) + "/state";
mqtt.unsubscribe(s.c_str());
publishState(i); // confirm actual state
}
}
}
What the restore window does
When the ESP32 reboots after a power cut, the relays start in the safe OFF position. On the first connection the sketch briefly subscribes to its own retained state topics. The broker immediately delivers the last state it holds, the sketch applies it, and after two seconds it unsubscribes and republishes the actual state. If you would rather have relays always start off, set restoreWindow to false. For critical loads such as heaters, "always off after reboot" is usually the safer policy.
Keep in mind that this relies on the broker still holding the retained message. Retained messages live as long as the broker keeps them, so treat restore as a convenience rather than a guarantee, and design the load so that OFF is always an acceptable state.
Test the Relay from Your Computer
Open a terminal and watch everything under your device prefix:
mosquitto_sub -h broker.connectmq.com -p 1883 -i watch-7f3a91 -v -t "connectmq/demo/relay-7f3a91/#"
In a second terminal, send commands:
mosquitto_pub -h broker.connectmq.com -p 1883 -i cmd-7f3a91 -t "connectmq/demo/relay-7f3a91/relay/1/set" -m ON
mosquitto_pub -h broker.connectmq.com -p 1883 -i cmd-7f3a91 -t "connectmq/demo/relay-7f3a91/relay/1/set" -m TOGGLE
You should hear the click and see the retained state echoed back:
connectmq/demo/relay-7f3a91/status online
connectmq/demo/relay-7f3a91/relay/1/state ON
connectmq/demo/relay-7f3a91/relay/1/state OFF
Now unplug the ESP32. Within roughly one and a half keep-alive intervals the broker publishes the Last Will and the subscriber prints status offline. That is exactly the signal a dashboard needs to grey out the switch. Because broker.connectmq.com is a public MQTT test broker, anyone who guesses your topic can switch your relay, so never leave a real load connected to the public area.
Troubleshooting Checklist
- Relay clicks at boot. You are on a strapping pin or the module is active-low and the pin floats before
setup()runs. Move to GPIO 26/27/32/33 and set the pin level beforepinModewhere possible. - Relay never switches although the LED on the module lights. Many modules need 5 V on VCC for the coil; 3.3 V lights the LED but cannot pull in the contacts. Some boards also need the
JD-VCCjumper fitted. - Commands work once, then stop. Check the serial log for
state=-3(connection lost). Using the same client ID on two devices makes them kick each other off repeatedly. - ESP32 resets when the relay switches. Inductive loads cause supply dips and noise. Power the relay coil from a separate 5 V supply with a shared ground, and use a snubber or flyback protection on the load side.
- State topic shows the wrong value after reboot. Something else publishes retained messages to the
statetopic. Only the device itself should ever publish there. - Nothing arrives. Topic names are case-sensitive, and a stray trailing slash creates a different topic.
Move to Production with Credentials and TLS
For a real installation, create a broker in the ConnectMQ portal and use the generated username, password and client ID. Switch WiFiClient to WiFiClientSecure, connect to port 8883 and pass the credentials to connect():
#include <WiFiClientSecure.h>
WiFiClientSecure net;
// in setup(): net.setCACert(ROOT_CA_PEM); // ISRG Root X1 (Let's Encrypt)
mqtt.setServer("broker.connectmq.com", 8883);
mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD",
willTopic.c_str(), 1, true, "offline");
Because client IDs are bound to issued credentials, use the client ID the portal generated instead of an invented one. The full certificate setup, including time sync and memory considerations, is covered in ESP32 MQTT over SSL/TLS. If your relay node has to survive flaky Wi-Fi, the backoff strategy in ESP32 MQTT reconnect logic is worth adding as well.
A fully managed MQTT service matters most for actuators: a relay you cannot reach is worse than a sensor you cannot read. With ConnectMQ you get TLS on 8883, per-device credentials, and a portal where you can watch relay/+/state in the live topic explorer or publish a test command from the in-browser client without touching a terminal.
Ideas to Extend the Relay Node
- Timers. Accept
{"state":"ON","seconds":600}as JSON and switch off automatically after ten minutes. Keep the timer on the device, so it still turns off if the network drops. - Local button. Add a push button that toggles the relay and publishes the new state, so the physical switch and the app never disagree.
- Interlocks. For motor reversing or two-way valves, refuse to turn both channels on at once in firmware, not in the app.
- Servo or LED siblings. The same command/state pattern works for servo control over MQTT and addressable LED strips.
Get Started with ConnectMQ
Create a free Serverless broker at app.connectmq.com, no credit card required. You get credentials in under a minute, TLS out of the box, and a live topic explorer to watch your relays switch. When you outgrow the free tier, the same firmware moves to a paid or dedicated plan by changing nothing but credentials.
Frequently Asked Questions
Should the relay command topic be retained?
No. Retain the state topic, not the command topic. A retained command would be replayed on every reconnect and can switch a load unexpectedly.
Which QoS should I use for relay commands?
QoS 1 is the practical choice: the command is delivered at least once, and because ON and OFF are idempotent, a duplicate does no harm. PubSubClient can subscribe with QoS 0 or 1.
Can I control multiple relays with one ESP32?
Yes. Add the pins to RELAY_PINS and each one gets its own relay/<n>/set and relay/<n>/state topics. A single wildcard subscription to relay/+/set also works if you parse the channel number from the topic.
How do I know if the ESP32 is offline?
Subscribe to the retained status topic. The ESP32 publishes online when it connects, and the broker publishes the Last Will message offline if the connection drops without a clean disconnect.



