ESP32-CAM MJPEG Video Streaming with a Minimal Custom Web Server

The CameraWebServer example is excellent for testing, but it carries a large web page, face detection code and dozens of settings you may never use. For a real project, such as a doorbell camera or a robot view, it is easier to maintain a short server that does exactly what you need.

In this tutorial you will write a streaming server of about a hundred lines. It serves a viewer page on port 80, a single JPEG snapshot at /jpg, and a live MJPEG stream on port 81 that works in any browser and in tools such as VLC. You will also learn how MJPEG over HTTP actually works, which helps when you connect the camera to other software.

How MJPEG Streaming Works

MJPEG is simply a sequence of separate JPEG images. Over HTTP it uses the content type multipart/x-mixed-replace with a boundary string. The server sends the header once, then repeats three things forever: a boundary line, a small part header with the JPEG length, and the JPEG bytes. Each new part replaces the previous image in the browser's img element, so it looks like video.

HTTP/1.1 200 OK
Content-Type: multipart/x-mixed-replace;boundary=frame

--frame
Content-Type: image/jpeg
Content-Length: 18342

<JPEG bytes>
--frame
Content-Type: image/jpeg
Content-Length: 18511

<JPEG bytes>
...

Because the OV2640 produces JPEG in hardware, the ESP32 does almost no image work; it just copies frame buffers to the network. Frame rate is therefore limited mostly by frame size, JPEG quality and Wi-Fi throughput.

Server Design

URLPortHandlerResponse
/80index_handlerSmall HTML page that shows the stream
/jpg80jpg_handlerOne JPEG image, then the connection closes
/stream81stream_handlerEndless MJPEG stream until the client disconnects

The ESP-IDF esp_http_server runs each server instance in one task, and a stream handler never returns while a viewer is connected. Running the stream on a second instance on port 81 keeps the page and snapshot URL responsive. Each instance needs its own ctrl_port, an internal UDP port used to control the server task.

Complete Code

Minimal MJPEG streaming server for the AI-Thinker ESP32-CAM
#include <WiFi.h>
#include "esp_camera.h"
#include "esp_http_server.h"

const char *ssid = "YourWiFiName";
const char *password = "YourWiFiPassword";

// 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

#define BOUNDARY "frame"
static const char *STREAM_TYPE = "multipart/x-mixed-replace;boundary=" BOUNDARY;
static const char *STREAM_SEP  = "\r\n--" BOUNDARY "\r\n";
static const char *STREAM_PART = "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n";

httpd_handle_t webServer = NULL;
httpd_handle_t streamServer = NULL;

bool initCamera() {
  camera_config_t c = {};
  c.ledc_channel = LEDC_CHANNEL_0;
  c.ledc_timer   = LEDC_TIMER_0;
  c.pin_d0 = Y2_GPIO_NUM;  c.pin_d1 = Y3_GPIO_NUM;
  c.pin_d2 = Y4_GPIO_NUM;  c.pin_d3 = Y5_GPIO_NUM;
  c.pin_d4 = Y6_GPIO_NUM;  c.pin_d5 = Y7_GPIO_NUM;
  c.pin_d6 = Y8_GPIO_NUM;  c.pin_d7 = Y9_GPIO_NUM;
  c.pin_xclk = XCLK_GPIO_NUM;   c.pin_pclk = PCLK_GPIO_NUM;
  c.pin_vsync = VSYNC_GPIO_NUM; c.pin_href = HREF_GPIO_NUM;
  c.pin_sccb_sda = SIOD_GPIO_NUM; c.pin_sccb_scl = SIOC_GPIO_NUM;
  c.pin_pwdn = PWDN_GPIO_NUM;   c.pin_reset = RESET_GPIO_NUM;
  c.xclk_freq_hz = 20000000;
  c.pixel_format = PIXFORMAT_JPEG;
  c.frame_size   = FRAMESIZE_VGA;     // 640x480 streams smoothly
  c.jpeg_quality = 12;
  c.fb_count     = 2;                 // one filling while one is sent
  c.fb_location  = CAMERA_FB_IN_PSRAM;
  c.grab_mode    = CAMERA_GRAB_LATEST;
  return esp_camera_init(&c) == ESP_OK;
}

// Viewer page: builds the stream URL on port 81 from the page address
static esp_err_t index_handler(httpd_req_t *req) {
  static const char page[] =
    "<!DOCTYPE html><html><head><meta name='viewport' content='width=device-width'>"
    "<title>ESP32-CAM</title></head><body style='margin:0;background:#111'>"
    "<img id='v' style='width:100%;max-width:800px;display:block;margin:auto'>"
    "<script>document.getElementById('v').src="
    "location.protocol+'//'+location.hostname+':81/stream';</script>"
    "</body></html>";
  httpd_resp_set_type(req, "text/html");
  return httpd_resp_send(req, page, HTTPD_RESP_USE_STRLEN);
}

// Single snapshot
static esp_err_t jpg_handler(httpd_req_t *req) {
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) {
    httpd_resp_send_500(req);
    return ESP_FAIL;
  }
  httpd_resp_set_type(req, "image/jpeg");
  httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=snapshot.jpg");
  esp_err_t res = httpd_resp_send(req, (const char *)fb->buf, fb->len);
  esp_camera_fb_return(fb);
  return res;
}

// Endless MJPEG stream
static esp_err_t stream_handler(httpd_req_t *req) {
  char partHeader[64];
  esp_err_t res = httpd_resp_set_type(req, STREAM_TYPE);
  if (res != ESP_OK) return res;
  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");

  uint32_t frames = 0;
  unsigned long tStart = millis();
  while (true) {
    camera_fb_t *fb = esp_camera_fb_get();
    if (!fb) {
      Serial.println("Frame capture failed");
      res = ESP_FAIL;
      break;
    }
    int hlen = snprintf(partHeader, sizeof(partHeader), STREAM_PART, (unsigned)fb->len);
    res = httpd_resp_send_chunk(req, STREAM_SEP, strlen(STREAM_SEP));
    if (res == ESP_OK) res = httpd_resp_send_chunk(req, partHeader, hlen);
    if (res == ESP_OK) res = httpd_resp_send_chunk(req, (const char *)fb->buf, fb->len);
    esp_camera_fb_return(fb);       // return buffer before checking errors
    if (res != ESP_OK) break;       // viewer closed the page

    frames++;
    if (millis() - tStart >= 5000) {  // print frame rate every 5 s
      Serial.printf("Streaming %.1f fps\n", frames * 1000.0 / (millis() - tStart));
      frames = 0;
      tStart = millis();
    }
  }
  Serial.println("Stream client disconnected");
  return res;
}

void startServers() {
  httpd_config_t config = HTTPD_DEFAULT_CONFIG();
  config.server_port = 80;

  httpd_uri_t indexUri = {};
  indexUri.uri = "/";
  indexUri.method = HTTP_GET;
  indexUri.handler = index_handler;

  httpd_uri_t jpgUri = {};
  jpgUri.uri = "/jpg";
  jpgUri.method = HTTP_GET;
  jpgUri.handler = jpg_handler;

  if (httpd_start(&webServer, &config) == ESP_OK) {
    httpd_register_uri_handler(webServer, &indexUri);
    httpd_register_uri_handler(webServer, &jpgUri);
  }

  config.server_port = 81;          // second instance for the stream
  config.ctrl_port += 1;            // each instance needs its own ctrl port
  httpd_uri_t streamUri = {};
  streamUri.uri = "/stream";
  streamUri.method = HTTP_GET;
  streamUri.handler = stream_handler;
  if (httpd_start(&streamServer, &config) == ESP_OK) {
    httpd_register_uri_handler(streamServer, &streamUri);
  }
}

void setup() {
  Serial.begin(115200);
  if (!initCamera()) {
    Serial.println("Camera init failed");
    while (true) delay(1000);
  }
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  WiFi.setSleep(false);             // lower latency while streaming
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.printf("\nOpen http://%s/\n", WiFi.localIP().toString().c_str());
  startServers();
}

void loop() {
  delay(10000);                     // all work happens in the server tasks
}

How the Code Works

  • fb_count = 2 with CAMERA_GRAB_LATEST lets the driver fill one buffer while the other is being sent, and always gives the newest frame, so latency stays low.
  • httpd_resp_send_chunk() uses chunked transfer encoding, so the stream needs no total length. When a viewer leaves, the send fails and the loop exits.
  • The frame buffer is returned with esp_camera_fb_return() before the error check. Forgetting this on any path freezes the camera after the buffers run out.
  • The viewer page builds the stream URL from location.hostname, so it works with whatever IP address the router assigns.
  • WiFi.setSleep(false) disables modem sleep, which otherwise adds delay between frames.

To watch in VLC, choose Media > Open Network Stream and enter http://IP:81/stream. The /jpg URL is useful for home automation software that polls still images.

Tuning Frame Rate and Latency

The Serial Monitor prints the achieved frame rate every five seconds, so you can change one setting at a time and see its real effect instead of guessing. Work through the factors below in order, because the first ones usually matter most.

FactorWhat to tryTrade-off
Frame sizeStep down from VGA to CIF or QVGALess detail, many more frames per second
JPEG qualityRaise jpeg_quality from 12 to 15 or 20Smaller frames, more visible compression blocks
Wi-Fi signalMove the board or fit an external antenna on the IPEX connectorExternal antenna needs the selection resistor moved
Scene contentTest the same scene each timeBusy, detailed scenes give larger JPEG files
XCLKKeep 20 MHz unless frames are corrupt10 MHz is more stable but halves sensor speed

Latency, the delay between movement and what you see, is a different problem from frame rate. With two buffers and CAMERA_GRAB_LATEST, the frame sent is never older than one frame time. If you see seconds of delay, the cause is usually buffering in the viewer software, not the ESP32. Browsers show MJPEG with very little buffering; VLC adds a network cache that you can reduce in its advanced options.

Extending the Server

To add more features, write another handler and register it on the port 80 server. For example, a /flash?on=1 handler can read the query string with httpd_req_get_url_query_str() and httpd_query_key_value(), then switch GPIO 4. Keep every handler short and return quickly, because they all share the single task of the port 80 server. Increase config.max_uri_handlers if you register more than the default number of URLs.

Troubleshooting

  • Stream works for one viewer only: by design, the single stream task serves one client. For several viewers, relay the stream through a computer or an NVR.
  • Low frame rate: reduce to CIF or QVGA, raise jpeg_quality to 15, move closer to the router, or fit an external antenna.
  • httpd_start fails for the second server: the ctrl_port was not changed, or the first server failed earlier.
  • Frames freeze after a few seconds: a frame buffer is not being returned on some code path.

Summary

MJPEG over HTTP is a never-ending multipart response with one JPEG per part. With esp_http_server you can serve it in a short handler loop, keep it on its own port so the rest of the server stays responsive, and add a snapshot URL and a tiny viewer page. This minimal server is the base for the robot, NVR and home automation projects that follow.