Raspberry Pi: live camera stream in the browser with Picamera2 MJPEG
Stream a Raspberry Pi camera feed live into any browser with Picamera2 and MJPEG. One Python file, no plugins, works on your phone over Wi-Fi.
The cheapest possible baby monitor, porch cam, or 3D-print-watch cam is a Pi with a camera module and 40 lines of Python. No RTSP server, no NVR, no cloud subscription: the Pi serves one HTTP URL, and every browser on your network (phone, laptop, tablet) shows the live feed. The format is MJPEG, which is the simplest streaming format that exists: a sequence of JPEG images sent one after another down an HTTP connection. The browser reassembles them into motion. That simplicity is why it works everywhere and why the script is short.
The trap is confusing this with the ESP32 camera stream. I have an ESP32 tutorial that streams MJPEG too, and the pattern looks similar, but the ESP32 does it in C++ with its own web server and tops out around 640x480 before the frame rate dies. The Pi does 1080p at 30 fps without breaking a sweat because it has a real OS and hardware encoding. If you are choosing between the two, choose by where the camera needs to live: battery and weatherproof means ESP32 (the trail camera tutorial), mains power and convenience means the Pi.
What you need
Needed
- Raspberry Pi 4 or Pi 5 (a Pi 3 or Zero 2 W works at 720p; the stream
- Camera Module v3 (or v2, or a USB webcam via OpenCV, same script
- Raspberry Pi OS Bookworm with
python3-picamera2(preinstalled on - The camera already proven to work: run
rpicam-still -o test.jpg - About 30 minutes
Nice to have
- An anti-static wristband (cheap insurance around the Pi’s GPIO header)
- A small screwdriver kit for the case and camera ribbon clips
Install
On a desktop image, python3-picamera2 is already installed. On Lite:
sudo apt update
sudo apt install -y python3-picamera2
That is the only dependency. The web server in this tutorial is the Python standard library, which is the point: no Flask, no nginx, no extra moving parts for a one-file streamer.
Wiring
CSI ribbon into the camera port, contacts facing the correct way for your board (heat sink side on a Pi 4), latch closed. No GPIO, no breadboard. If you have a v1/v2 camera on a Pi 5, you need the smaller 22-pin to 15-pin adapter cable; the v3 ships with one.
The code
One file, standard library plus Picamera2. Run it, then open
http://<pi-ip>:8000 in any browser on the network.
#!/usr/bin/env python3
"""MJPEG stream from a Pi camera in ~60 lines. No dependencies
beyond Picamera2 and the Python standard library.
URLs:
/ -> a tiny HTML page with the stream embedded
/stream -> the raw MJPEG stream (open this directly in a browser)
"""
import io
import socket
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from picamera2 import Picamera2
WIDTH, HEIGHT, FPS = 1280, 720, 30
picam = Picamera2()
picam.configure(picam.create_video_configuration(
main={"size": (WIDTH, HEIGHT), "format": "RGB888"},
controls={"FrameRate": FPS}))
picam.start()
PAGE = b"""<!DOCTYPE html><html><head><title>Pi cam</title></head>
<body style="margin:0;background:#111">
<img src="/stream" style="width:100%;max-width:1280px;display:block;margin:auto">
</body></html>"""
class StreamHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/stream":
self.send_response(200)
# multipart/x-mixed-replace is the whole MJPEG trick:
# the server keeps replacing the "image" with the next one.
self.send_header("Content-Type",
"multipart/x-mixed-replace; boundary=frame")
self.end_headers()
try:
while True:
buf = io.BytesIO()
picam.capture_file(buf, format="jpeg")
jpeg = buf.getvalue()
self.wfile.write(b"--frame\r\n"
b"Content-Type: image/jpeg\r\n"
b"Content-Length: " +
str(len(jpeg)).encode() + b"\r\n\r\n")
self.wfile.write(jpeg)
self.wfile.write(b"\r\n")
except (ConnectionAbortedError, BrokenPipeError):
pass # viewer closed the tab; that is fine
else:
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(PAGE)
def log_message(self, *args):
pass # keep the console quiet while streaming
def lan_ip():
"""Find this Pi's LAN address without touching the network."""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("10.255.255.255", 1))
return s.getsockname()[0]
finally:
s.close()
server = ThreadingHTTPServer(("0.0.0.0", 8000), StreamHandler)
print(f"Serving at http://{lan_ip()}:8000 (ctrl-C to stop)")
server.serve_forever()
The one line that does the streaming magic is the Content-Type with
multipart/x-mixed-replace. Tell the browser “this never ends and
each part is a fresh JPEG” and every browser from Safari on the
iPhone to Firefox on the desktop just plays it. No JavaScript, no
WebSocket, no player install. That is why this trick from 2002 is
still the correct answer in 2026.
Two notes on quality. Resolution: 720p at 30 fps uses maybe 25% of a Pi 4’s CPU (the hardware ISP does the scaling, Python does the JPEG encode per frame, and that encode is the cost). Bump to 1080p on a Pi 5, drop to 640x480 on a Zero. Latency: MJPEG over your LAN is well under a second end to end, which is why it feels good for a door camera where HLS-based setups feel like a satellite call.
This stream is unauthenticated by design. Keep it on the LAN. If you want it reachable from outside, tunnel to it with WireGuard (the WireGuard tutorial covers the phone-as-peer setup) rather than port-forwarding the Pi’s port 8000 to the internet. A camera URL that answers to the whole internet will be found and watched by bots, and not by friendly ones.
What you learned
- MJPEG is HTTP multipart with JPEG parts: the simplest live video that works in every browser with zero client setup.
- Picamera2’s
capture_fileinto an in-memory buffer plus a loop is the entire streaming engine; theThreadingHTTPServermakes it multi-viewer. - The pattern in 1 sentence: capture JPEG, write it into a never-ending HTTP response, let the browser do the rest.
When something breaks
- The page loads but the stream is a broken image. Something else
on the Pi is already using port 8000 (or the camera never started).
Check the console output, then
sudo lsof -i :8000and kill the other process or change the port. - “RuntimeError: camera 0 is in use”. Another Picamera2 process is
running (e.g. the gesture script from the other tutorial, or a
leftover rpicam-still that crashed).
pkill -f picamera2or reboot; only one process owns the camera at a time. - The stream plays for a few seconds then freezes. Almost always a Wi-Fi drop on the viewer’s device or a browser that throttled a background tab. On the Pi side the handler keeps writing and only notices the dead viewer when the socket buffer fills, which is why the handler exits on BrokenPipeError instead of hanging forever.
- Frame rate is terrible on a Zero 2 W. Drop to 640x480 and 15 fps in the config constants. The JPEG encode dominates on the small boards and no amount of tuning the HTTP layer fixes that.
- It works from the laptop but not the phone. The phone is on guest Wi-Fi or a different VLAN that cannot reach the Pi. Same subnet first, VPN second (WireGuard makes the phone behave as if it is on the LAN).
What to build next
Record motion events while streaming by combining this with the SQLite logging tutorial: log a row per detected event, then pull the database with FileZilla to review. The gesture recognition tutorial runs on the same camera, and a clean split is one process streaming while a second process subscribes to frames for gesture logic (or run them at different times; the camera is single-owner). For alerts when the stream shows something interesting, the email alerts tutorial pipes a JPEG straight into an msmtp message. —Brian