ESP32-CAM: live MJPEG streaming web server in the browser
Stream live video from an ESP32-CAM to any browser on your Wi-Fi. MJPEG over HTTP, the pattern behind every DIY security camera.
The ESP32-CAM is a $10 board with a real camera sensor on it, and the
single most useful thing you can do with it is put live video in a
browser. MJPEG (Motion JPEG) is the format that makes it work with
zero client software: the server sends an endless HTTP response, each
chunk a JPEG frame, and the browser renders them in an tag
(e.g. the same trick every network IP camera uses).
This tutorial builds a working stream you can watch from your phone in about 40 minutes.
What you need
- ESP32-CAM board (the AI-Thinker model, about $10, camera included)
- FTDI USB-serial adapter (the ESP32-CAM has no USB port; you need this to program it, about $2)
- microSD card (optional, for the storage features later)
- 4 female-female jumper wires
- A 5V supply that can deliver at least 500 mA (a phone charger works; the camera brownouts on weak power and this is the number one “camera init failed” cause)
Programming setup (the FTDI dance)
The ESP32-CAM exposes serial on GPIO 1/3, not USB:
| FTDI | ESP32-CAM |
|---|---|
5V | 5V |
GND | GND |
TX | U0R (GPIO 3) |
RX | U0T (GPIO 1) |
| (n/a) | GPIO 0 to GND during power-up (download mode) |
The GPIO 0-to-GND jumper is the step everyone misses. Ground GPIO 0, power the board, then hit upload. After upload, remove the jumper and press reset. If the upload fails with “Failed to connect”, the board was not in download mode.
The code
#include "esp_camera.h"
#include <WiFi.h>
#include <WebServer.h>
// AI-Thinker 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
WebServer server(80);
void setup() {
Serial.begin(115200);
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 = Y3_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; // 640x480, the reliable one
config.jpeg_quality = 12; // 10-12 is the sweet spot
config.fb_count = 1;
if (esp_camera_init(&config) != ESP_OK) {
Serial.println("Camera init failed (power supply, almost always)");
delay(3000);
ESP.restart();
}
WiFi.mode(WIFI_STA);
WiFi.begin("your-wifi", "your-password");
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
Serial.print("\nStream at: http://");
Serial.println(WiFi.localIP());
server.on("/", handleStream);
server.begin();
}
void handleStream() {
WiFiClient client = server.client();
String response = "HTTP/1.1 200 OK\r\n"
"Content-Type: multipart/x-mixed-replace; boundary=frame\r\n\r\n";
server.sendContent(response);
while (client.connected()) {
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) continue;
server.sendContent("--frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
+ String(fb->len) + "\r\n\r\n", "text/plain");
client.write(fb->buf, fb->len);
server.sendContent("\r\n");
esp_camera_fb_return(fb);
delay(80); // ~12 fps ceiling; lower = fewer dropouts
}
}
void loop() {
server.handleClient();
}
Upload, note the IP from Serial Monitor, open http://that-ip/ in any
browser on the same network. Live video, one client at a time.
The resolution ladder
| frame_size | Pixels | What it is for |
|---|---|---|
| FRAMESIZE_QQVGA | 160x120 | Machine vision, fast |
| FRAMESIZE_VGA | 640x480 | The reliable default |
| FRAMESIZE_SVGA | 800x600 | Needs good Wi-Fi |
| FRAMESIZE_UXGA | 1600x1200 | Still photos, too slow for streaming |
Start at VGA. People jump straight to UXGA, get 2 fps and dropped frames, and conclude the board is junk. It is not: the ESP32’s RAM holds exactly one frame buffer, and big frames choke the Wi-Fi send loop (e.g. quality 10 at UXGA is 300 KB per frame; at 12 fps that is 3.6 MB/s sustained, which is most of what the radio has).
The frame buffer trick
fb_count = 1 is the conservative setting. Setting it to 2 gives the
camera a double buffer: one frame being captured while the other is
being sent. On boards with PSRAM (most ESP32-CAMs have 4 MB), raising
it and lowering grab_mode latency gives noticeably smoother video.
Try config.fb_count = 2 after the basic version works.
One client, and why
This server handles exactly one streaming client at a time (the while loop holds the connection). A second browser tab will stall the first. That is fine for a driveway camera you glance at. For multi-viewer you would put the stream behind a reverse proxy that fans out, or serve snapshots instead of a stream (e.g. one JPEG refreshed every 500 ms is 90% of the utility for a front-door camera).
What you learned
- MJPEG is an endless multipart HTTP response; any browser renders it.
- Frame size, JPEG quality, and fps trade against Wi-Fi bandwidth.
- Camera init failures are power problems before they are code problems.
When something breaks
- “Camera init failed 0x20001”: power supply. A weak 3.3V rail or thin USB cable brownouts the OV2640. Use a real 5V/500mA+ supply and a short thick USB cable.
- Stream connects then freezes: Wi-Fi congestion, or you raised
the frame size too far. Drop to VGA and
delay(100)between frames. - Brownout detector in serial output: same root cause as camera init, the supply again. The ESP32-CAM is honest about being hungry.
- “Failed to connect” on upload: GPIO 0 was not grounded at power-up. Power cycle with the jumper in place.
What to build next
- The photo-on-motion tutorial adds PIR triggering and sends the image to your phone via ntfy.
- The trail camera project is the battery-powered version.
- The book IoT with ESP32 bundles the camera tutorials.