ESP32 and Arduino

ESP32-CAM MQTT: Capture JPEG Snapshots and Publish Them as Binary Payloads

Turn an AI-Thinker ESP32-CAM into an MQTT camera that publishes JPEG snapshots on demand or on a timer, stays inside broker message limits and saves every frame with a small Python subscriber.

AI-Thinker ESP32-CAM board publishing a JPEG snapshot to a cloud broker in an esp32-cam mqtt project
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

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_SVGA and above may exceed 64 KB; check fb->len in 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:

  1. beginPublish(topic, length, retained) writes the MQTT header with the final length.
  2. write(buffer, length) sends the JPEG bytes straight from the camera frame buffer.
  3. 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:

  1. Sign up for ConnectMQ, create a broker and copy the generated username, password and client ID.
  2. Replace WiFiClient with WiFiClientSecure, load the ISRG Root X1 certificate with setCACert() and change the port to 8883.
  3. 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.
  4. Raise MAX_PAYLOAD to 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

  1. "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.
  2. Camera init failed: 0x105 or similar. The ribbon cable is loose or the pin map does not match your board variant.
  3. Snapshot "FAILED" but small messages work. The frame exceeds the broker limit or the socket timed out mid-write. Lower frame_size, raise jpeg_quality number, or increase setSocketTimeout().
  4. 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.
  5. Images look green or dark at first. The OV2640 needs a few frames to settle auto-exposure; capture and discard one frame after boot.
  6. 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.

Related articles

ESP-IDF MQTT Example: Build an esp-mqtt Client from Scratch

Build an ESP-IDF project from an empty folder with the esp-mqtt component: Wi-Fi station setup, the MQTT event handler, publish and subscribe, MQTT 5 properties and TLS with the certificate bundle.

8 min read

Using esp-mqtt MQTT 5 Features on ESP32 with ESP-IDF

A hands-on ESP-IDF project that enables MQTT 5.0 in esp-mqtt and uses user properties, message expiry, topic aliases, session expiry and response topics against a real MQTT 5 broker.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.