This ESP32-CAM MQTT tutorial shows how to capture JPEG snapshots on an AI-Thinker ESP32-CAM and publish each frame as a raw binary MQTT payload. By the end you will have a camera that sends a picture on a timer or on command, a Python script that saves every image to disk, and a clear picture of the message-size limits that decide whether a frame arrives at all.
Why Send Camera Images over MQTT?
MQTT is usually associated with tiny sensor readings, but the protocol itself does not care what is inside a payload. A JPEG is just a byte array, and the specification allows payloads far larger than any snapshot a small camera will produce. Sending images through the same broker as your telemetry has real advantages:
- One connection, one security model. The camera uses the same credentials, TLS settings and topic conventions as every other device.
- Event-driven capture. A motion sensor, a door contact or a dashboard button can publish a command and the camera answers with a frame. No polling, no HTTP server on the device.
- Fan-out for free. Several subscribers (an archive script, a dashboard, an alert flow) can receive the same frame without the camera doing extra work.
MQTT is not a video protocol, though. It is a good fit for snapshots every few seconds or on events, and a poor fit for continuous video streaming. If you need live video, use a dedicated streaming protocol and keep MQTT for control and metadata.
Hardware and Software You Need
| Item | Notes |
|---|---|
| AI-Thinker ESP32-CAM with OV2640 | The most common module; the pin map below is for this board |
| USB-to-serial adapter (FTDI or ESP32-CAM-MB) | GPIO0 must be pulled to GND while flashing |
| Stable 5 V supply, at least 1 A recommended | Brown-outs during capture are the number one cause of resets |
| Arduino IDE 2.x with the esp32 board package | Board: "AI Thinker ESP32-CAM" |
| PubSubClient library by Nick O'Leary | Installed from the Library Manager |
| Python 3 with paho-mqtt 2.x | For the receiving script |
If you have never connected an ESP32 to a broker before, the ESP32 IoT tutorial covers Wi-Fi, PubSubClient basics and reconnect logic in more depth.
Message Size Is the Real Constraint
Before writing any code, decide on a resolution, because the size of the JPEG decides which broker you can send it to and how much RAM you need.
| Where you publish | Maximum message size |
|---|---|
| Public test area of broker.connectmq.com | 64 KB |
| Serverless (Free) plan | 256 KB |
| Serverless (Paid) plan | 1 MB |
JPEG size depends on resolution, the jpeg_quality setting (lower number means higher quality and larger files) and the scene itself. A busy, detailed scene compresses worse than a white wall. Practical starting points:
FRAMESIZE_QVGA(320x240) produces small frames that fit easily under 64 KB, ideal for testing on the public MQTT test broker.FRAMESIZE_VGA(640x480) is a good compromise for monitoring and usually fits on the free plan.FRAMESIZE_SVGAand above may exceed 64 KB; checkfb->lenin the serial monitor before relying on them.
The sketch below checks the frame length against a limit and skips oversized frames instead of letting the broker drop the connection.
Streaming Large Payloads with PubSubClient
PubSubClient normally copies the topic and payload into an internal buffer, which defaults to 256 bytes. You could raise it with setBufferSize(), but allocating a 50 KB buffer on top of the camera frame buffer wastes RAM. The better approach is the streaming API:
beginPublish(topic, length, retained)writes the MQTT header with the final length.write(buffer, length)sends the JPEG bytes straight from the camera frame buffer.endPublish()completes the packet.
The payload never gets copied, so even large frames only need a small MQTT buffer. Note that PubSubClient publishes at QoS 0, which is fine for snapshots: if one is lost, the next one is seconds away. For more on buffer tuning in general, see PubSubClient buffer size.
The ESP32-CAM MQTT Sketch
The topic layout uses a unique prefix so your frames do not collide with anyone else on the public broker:
| Topic | Direction | Payload |
|---|---|---|
connectmq/demo/cam-7f3a/snapshot |
camera publishes | raw JPEG bytes |
connectmq/demo/cam-7f3a/status |
camera publishes, retained | online / offline (Last Will) |
connectmq/demo/cam-7f3a/cmd |
camera subscribes | capture, flash_on, flash_off |
Replace cam-7f3a with your own random string.
#include <WiFi.h>
#include <PubSubClient.h>
#include "esp_camera.h"
// ---------- Wi-Fi and MQTT settings ----------
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* CLIENT_ID = "cam-7f3a-esp32"; // must be non-empty and unique
const char* TOPIC_SNAP = "connectmq/demo/cam-7f3a/snapshot";
const char* TOPIC_STATUS = "connectmq/demo/cam-7f3a/status";
const char* TOPIC_CMD = "connectmq/demo/cam-7f3a/cmd";
const size_t MAX_PAYLOAD = 60 * 1024; // stay under the 64 KB public limit
const uint32_t CAPTURE_INTERVAL = 30000; // timed snapshot every 30 s
const int FLASH_LED_PIN = 4;
// ---------- AI-Thinker ESP32-CAM pin map ----------
#define PWDN_GPIO_NUM 32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 0
#define SIOD_GPIO_NUM 26
#define SIOC_GPIO_NUM 27
#define Y9_GPIO_NUM 35
#define Y8_GPIO_NUM 34
#define Y7_GPIO_NUM 39
#define Y6_GPIO_NUM 36
#define Y5_GPIO_NUM 21
#define Y4_GPIO_NUM 19
#define Y3_GPIO_NUM 18
#define Y2_GPIO_NUM 5
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM 23
#define PCLK_GPIO_NUM 22
WiFiClient net;
PubSubClient mqtt(net);
uint32_t lastCapture = 0;
volatile bool captureRequested = false;
bool initCamera() {
camera_config_t config = {};
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM; config.pin_d1 = Y3_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM; config.pin_d3 = Y5_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM; config.pin_d5 = Y7_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM; config.pin_d7 = Y9_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM;
config.pin_pclk = PCLK_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM;
config.pin_href = HREF_GPIO_NUM;
config.pin_sccb_sda = SIOD_GPIO_NUM;
config.pin_sccb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 20000000;
config.pixel_format = PIXFORMAT_JPEG;
config.frame_size = FRAMESIZE_VGA; // try QVGA if frames are too large
config.jpeg_quality = 12; // 10-63, lower = better quality, bigger file
config.fb_count = 1;
config.fb_location = CAMERA_FB_IN_PSRAM;
config.grab_mode = CAMERA_GRAB_LATEST;
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("Camera init failed: 0x%x\n", err);
return false;
}
return true;
}
void onMessage(char* topic, byte* payload, unsigned int length) {
String cmd;
for (unsigned int i = 0; i < length; i++) cmd += (char)payload[i];
cmd.trim();
if (cmd == "capture") captureRequested = true;
else if (cmd == "flash_on") digitalWrite(FLASH_LED_PIN, HIGH);
else if (cmd == "flash_off") digitalWrite(FLASH_LED_PIN, LOW);
}
void connectWifi() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.printf("\nWi-Fi OK, IP %s\n", WiFi.localIP().toString().c_str());
}
void connectMqtt() {
while (!mqtt.connected()) {
Serial.print("Connecting to MQTT... ");
// Last Will: broker publishes "offline" (retained) if the camera disappears
if (mqtt.connect(CLIENT_ID, TOPIC_STATUS, 1, true, "offline")) {
Serial.println("connected");
mqtt.publish(TOPIC_STATUS, "online", true);
mqtt.subscribe(TOPIC_CMD);
} else {
Serial.printf("failed, state=%d, retrying in 5 s\n", mqtt.state());
delay(5000);
}
}
}
void publishSnapshot() {
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Capture failed");
return;
}
if (fb->len > MAX_PAYLOAD) {
Serial.printf("Frame %u bytes exceeds limit, skipped\n", (unsigned)fb->len);
esp_camera_fb_return(fb);
return;
}
bool ok = mqtt.beginPublish(TOPIC_SNAP, fb->len, false);
if (ok) {
size_t sent = mqtt.write(fb->buf, fb->len);
ok = mqtt.endPublish() && sent == fb->len;
}
Serial.printf("Snapshot %u bytes -> %s\n", (unsigned)fb->len, ok ? "sent" : "FAILED");
esp_camera_fb_return(fb);
}
void setup() {
Serial.begin(115200);
pinMode(FLASH_LED_PIN, OUTPUT);
digitalWrite(FLASH_LED_PIN, LOW);
if (!initCamera()) {
delay(5000);
ESP.restart();
}
connectWifi();
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setCallback(onMessage);
mqtt.setKeepAlive(30);
mqtt.setSocketTimeout(10); // large writes need more than the default
connectMqtt();
}
void loop() {
if (WiFi.status() != WL_CONNECTED) connectWifi();
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
uint32_t now = millis();
if (captureRequested || now - lastCapture >= CAPTURE_INTERVAL) {
captureRequested = false;
lastCapture = now;
publishSnapshot();
}
}
Flash the board with GPIO0 tied to GND, then remove the jumper and press reset. The serial monitor should show the IP address, a successful MQTT connection and a line per snapshot with its size in bytes.
Receive and Save the Images in Python
On your computer, this script subscribes to the snapshot topic and writes each frame to a timestamped file. It also shows how to request a capture on demand.
import time
from datetime import datetime
from pathlib import Path
import paho.mqtt.client as mqtt
PREFIX = "connectmq/demo/cam-7f3a"
OUT = Path("snapshots")
OUT.mkdir(exist_ok=True)
def on_connect(client, userdata, flags, reason_code, properties):
print("Connected:", reason_code)
client.subscribe(f"{PREFIX}/snapshot", qos=0)
client.subscribe(f"{PREFIX}/status", qos=1)
def on_message(client, userdata, msg):
if msg.topic.endswith("/status"):
print("Camera status:", msg.payload.decode())
return
if not msg.payload.startswith(b"\xff\xd8"):
print("Payload is not a JPEG, ignored")
return
name = OUT / f"{datetime.now():%Y%m%d-%H%M%S-%f}.jpg"
name.write_bytes(msg.payload)
print(f"Saved {name} ({len(msg.payload)} bytes)")
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="cam-7f3a-viewer")
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883, keepalive=60)
client.loop_start()
time.sleep(2)
client.publish(f"{PREFIX}/cmd", "capture", qos=1) # ask for an immediate frame
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
client.loop_stop()
client.disconnect()
The check for the bytes FF D8 (the JPEG start-of-image marker) protects the script from saving garbage if someone else publishes text to your topic. You can also trigger a capture from the command line:
mosquitto_pub -h broker.connectmq.com -p 1883 -i cam-7f3a-cli -t connectmq/demo/cam-7f3a/cmd -m capture
Moving the ESP32-CAM MQTT Setup to Production
The public area is readable by everyone, so never point a camera at anything private while using it. For a real deployment, create a broker on a managed MQTT broker plan and switch the sketch to TLS:
- Sign up for ConnectMQ, create a broker and copy the generated username, password and client ID.
- Replace
WiFiClientwithWiFiClientSecure, load the ISRG Root X1 certificate withsetCACert()and change the port to 8883. - Call
mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", TOPIC_STATUS, 1, true, "offline"). ConnectMQ binds client IDs to issued credentials, so use the one from the portal. - Raise
MAX_PAYLOADto match your plan, for example just under 256 KB on the free Serverless plan.
TLS adds RAM use on top of the camera buffers. The AI-Thinker module has PSRAM, which keeps frame buffers out of internal memory, but if you see allocation failures, drop to one frame buffer and a smaller frame size. The ESP32 MQTT SSL guide walks through certificate loading step by step.
Troubleshooting Checklist
- "Brownout detector was triggered" on boot or capture. Use a solid 5 V supply and short cables; the camera and Wi-Fi radio draw current peaks together.
Camera init failed: 0x105or similar. The ribbon cable is loose or the pin map does not match your board variant.- Snapshot "FAILED" but small messages work. The frame exceeds the broker limit or the socket timed out mid-write. Lower
frame_size, raisejpeg_qualitynumber, or increasesetSocketTimeout(). - The camera disconnects right after a large publish. On the public broker, a message larger than 64 KB is rejected. Check the size printed in the serial log.
- Images look green or dark at first. The OV2640 needs a few frames to settle auto-exposure; capture and discard one frame after boot.
- Reconnect loops after Wi-Fi drops. Add backoff and keep the Last Will so dashboards show the camera offline; see ESP32 MQTT reconnect.
Design Tips for Camera Topics
Keep binary frames on their own topic and send metadata separately. A small JSON message on a snapshot/meta topic with the size, resolution and trigger reason lets dashboards react without downloading the image. Avoid retaining image messages on shared brokers unless you need "last picture" behaviour, because every new subscriber immediately receives the full JPEG. If several services consume the frames, MQTT fan-out handles it, and a cloud MQTT broker removes the need to run that infrastructure yourself.
Get Started with ConnectMQ
Test your camera on the free public area first, then move to a private broker when the images matter. ConnectMQ gives you a TLS endpoint, generated credentials and a live topic explorer to watch snapshot traffic arrive. Create a broker on the free Serverless plan at app.connectmq.com, no credit card required, and swap the host and credentials in the sketch.
Frequently Asked Questions
Can an ESP32-CAM stream video over MQTT?
It can send frames in quick succession, but MQTT is not designed for video and rate limits apply on shared brokers. Use MQTT for snapshots and event-triggered captures, and a streaming protocol for live video.
What is the largest image I can publish?
It depends on the broker: 64 KB on the public test area, 256 KB on the Serverless (Free) plan and 1 MB on Serverless (Paid). Check fb->len and choose a frame size that stays below your limit.
Do I need to increase the PubSubClient buffer for images?
Not if you use beginPublish(), write() and endPublish(). Those calls stream the frame directly, so the default buffer is enough for the header.
Should snapshot messages be retained?
Usually not. Retaining a large binary message means every new subscriber receives it immediately; retain only the small status topic unless you specifically want a "latest image" behaviour.



