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
| URL | Port | Handler | Response |
|---|---|---|---|
| / | 80 | index_handler | Small HTML page that shows the stream |
| /jpg | 80 | jpg_handler | One JPEG image, then the connection closes |
| /stream | 81 | stream_handler | Endless 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
#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 = 2withCAMERA_GRAB_LATESTlets 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.
| Factor | What to try | Trade-off |
|---|---|---|
| Frame size | Step down from VGA to CIF or QVGA | Less detail, many more frames per second |
| JPEG quality | Raise jpeg_quality from 12 to 15 or 20 | Smaller frames, more visible compression blocks |
| Wi-Fi signal | Move the board or fit an external antenna on the IPEX connector | External antenna needs the selection resistor moved |
| Scene content | Test the same scene each time | Busy, detailed scenes give larger JPEG files |
| XCLK | Keep 20 MHz unless frames are corrupt | 10 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_qualityto 15, move closer to the router, or fit an external antenna. - httpd_start fails for the second server: the
ctrl_portwas 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.