esp32 intermediate 40 min

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.

Code available for: ESP32 ArduinoArduino C
Published Sep 22, 2026

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:

Wire key: 5VGNDTXGPIORX
FTDIESP32-CAM
5V5V
GNDGND
TXU0R (GPIO 3)
RXU0T (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_sizePixelsWhat it is for
FRAMESIZE_QQVGA160x120Machine vision, fast
FRAMESIZE_VGA640x480The reliable default
FRAMESIZE_SVGA800x600Needs good Wi-Fi
FRAMESIZE_UXGA1600x1200Still 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.