Direct MJPEG streaming from ESP32-CAM works great on a local network, but the moment you need remote access across the internet — without port forwarding, without a static IP, without a VPN — you need a relay. Firebase Realtime Database is one of the simplest approaches: the ESP32-CAM encodes each JPEG frame as base64 and writes it to a database path, and any client anywhere in the world that is listening to that path gets the new frame instantly via Firebase's server-sent events. The result is 2–3 FPS remote video that requires zero network infrastructure beyond WiFi and a free Firebase account.

// What you'll build: An ESP32-CAM (AI-Thinker board, OV2640 camera) that captures JPEG frames at QVGA resolution, encodes them as base64, and writes them to Firebase Realtime Database at ~2 FPS. A plain HTML/JS viewer and a Flutter app both display the live stream by listening to the database path.
FULL BUILD DEMO ON YOUTUBE

Watch the complete build on the ESPSTACK YouTube channel →

// AD SLOT — IN-CONTENT RESPONSIVE

Prerequisites

RequirementNotes
ESP32-CAM (AI-Thinker)OV2640 camera module included; needs FTDI or UNO programmer
Firebase projectFree Spark plan is sufficient; enable Realtime Database
Firebase ESP32 libraryInstall Firebase Arduino Client Library for ESP8266 and ESP32 by Mobizt via Library Manager
Arduino IDE boardAI-Thinker ESP32-CAM under ESP32 by Espressif
PSRAM enabledEnable in Tools → PSRAM → OPI PSRAM (required for JPEG frame buffer)

Step 1 — Firebase Project Setup

In the Firebase console, create a new project and enable the Realtime Database. Set the rules to allow authenticated writes (or temporarily allow all writes for testing):

// Firebase Realtime Database rules (testing only — lock down for production)
{
  "rules": {
    "camera": {
      ".read": true,
      ".write": true
    }
  }
}

Note your database URL (e.g. https://your-project-default-rtdb.firebaseio.com) and generate a Web API key from Project Settings → General.

// Warning: Open database rules expose your data to anyone with the URL. For production, use Firebase Authentication and restrict writes to authenticated UIDs. The API key is public by nature but database rules are your security boundary.

Step 2 — Camera Initialization and JPEG Capture

#include "esp_camera.h"
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <FirebaseESP32.h>  // Mobizt library

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

void initCamera() {
  camera_config_t cfg;
  cfg.ledc_channel = LEDC_CHANNEL_0;
  cfg.ledc_timer   = LEDC_TIMER_0;
  cfg.pin_d0 = Y2_GPIO_NUM; cfg.pin_d1 = Y3_GPIO_NUM;
  cfg.pin_d2 = Y4_GPIO_NUM; cfg.pin_d3 = Y5_GPIO_NUM;
  cfg.pin_d4 = Y6_GPIO_NUM; cfg.pin_d5 = Y7_GPIO_NUM;
  cfg.pin_d6 = Y8_GPIO_NUM; cfg.pin_d7 = Y9_GPIO_NUM;
  cfg.pin_xclk   = XCLK_GPIO_NUM;
  cfg.pin_pclk   = PCLK_GPIO_NUM;
  cfg.pin_vsync  = VSYNC_GPIO_NUM;
  cfg.pin_href   = HREF_GPIO_NUM;
  cfg.pin_sccb_sda = SIOD_GPIO_NUM;
  cfg.pin_sccb_scl = SIOC_GPIO_NUM;
  cfg.pin_pwdn   = PWDN_GPIO_NUM;
  cfg.pin_reset  = RESET_GPIO_NUM;
  cfg.xclk_freq_hz = 20000000;
  cfg.pixel_format = PIXFORMAT_JPEG;
  cfg.frame_size   = FRAMESIZE_QVGA;  // 320×240 — keeps base64 under 30 KB
  cfg.jpeg_quality = 15;              // 10=best, 63=worst; 15 gives ~15 KB
  cfg.fb_count     = 1;
  cfg.grab_mode    = CAMERA_GRAB_WHEN_EMPTY;
  cfg.fb_location  = CAMERA_FB_IN_PSRAM;

  esp_err_t err = esp_camera_init(&cfg);
  if (err != ESP_OK) {
    Serial.printf("Camera init failed: 0x%x\n", err);
    ESP.restart();
  }
}
// AD SLOT — IN-CONTENT RESPONSIVE

Step 3 — Base64 Encode and Write to Firebase

#include "mbedtls/base64.h"

#define WIFI_SSID     "YourSSID"
#define WIFI_PASSWORD "YourPassword"
#define FIREBASE_HOST "your-project-default-rtdb.firebaseio.com"
#define FIREBASE_AUTH "your-database-secret-or-legacy-token"

FirebaseData fbdo;
FirebaseAuth auth;
FirebaseConfig config;

String encodeBase64(const uint8_t *data, size_t len) {
  size_t outLen = 0;
  // Calculate output buffer size (4/3 ratio + padding + null)
  size_t bufLen = ((len + 2) / 3) * 4 + 1;
  uint8_t *buf = (uint8_t *)ps_malloc(bufLen);
  if (!buf) return "";
  mbedtls_base64_encode(buf, bufLen, &outLen, data, len);
  buf[outLen] = '\0';
  String result = (char *)buf;
  free(buf);
  return result;
}

void setup() {
  Serial.begin(115200);
  initCamera();
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
  Serial.println("\nWiFi connected: " + WiFi.localIP().toString());

  config.host = FIREBASE_HOST;
  config.signer.tokens.legacy_token = FIREBASE_AUTH;
  Firebase.begin(&config, &auth);
  Firebase.reconnectWiFi(true);
}

void loop() {
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) { Serial.println("Camera capture failed"); delay(500); return; }

  String b64 = encodeBase64(fb->buf, fb->len);
  esp_camera_fb_return(fb);

  if (b64.length() == 0) { delay(200); return; }

  // Write frame to /camera/frame as a base64 string
  // Also write timestamp for clients to detect stale frames
  FirebaseJson json;
  json.set("frame", b64);
  json.set("ts", (int)(millis() / 1000));

  if (!Firebase.setJSON(fbdo, "/camera", json)) {
    Serial.println("Firebase write failed: " + fbdo.errorReason());
  } else {
    Serial.printf("Frame uploaded: %u bytes → %u B64 chars\n",
                  fb->len, b64.length());
  }
  delay(400); // ~2.5 FPS max; Firebase free tier has write limits
}

Step 4 — HTML/JS Viewer

<!DOCTYPE html>
<html>
<head>
  <title>ESP32-CAM Live Stream</title>
  <!-- Firebase SDK v9 compat -->
  <script src="https://www.gstatic.com/firebasejs/9.22.0/firebase-app-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/9.22.0/firebase-database-compat.js"></script>
</head>
<body>
  <h2>ESP32-CAM Live Stream</h2>
  <img id="cam" style="max-width:640px;border:2px solid #00f5ff;" />
  <p id="fps">FPS: --</p>
  <script>
    const firebaseConfig = {
      apiKey: "YOUR_API_KEY",
      authDomain: "your-project.firebaseapp.com",
      databaseURL: "https://your-project-default-rtdb.firebaseio.com",
      projectId: "your-project"
    };
    firebase.initializeApp(firebaseConfig);
    const db  = firebase.database();
    const ref = db.ref("/camera");
    const img = document.getElementById("cam");
    let lastTs = 0, frames = 0, fpsTimer = Date.now();

    ref.on("value", snap => {
      const data = snap.val();
      if (!data || !data.frame) return;
      img.src = "data:image/jpeg;base64," + data.frame;
      frames++;
      const now = Date.now();
      if (now - fpsTimer >= 1000) {
        document.getElementById("fps").textContent = "FPS: " + frames;
        frames = 0;
        fpsTimer = now;
      }
    });
  </script>
</body>
</html>

Step 5 — Flutter Viewer with firebase_database

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^2.24.0
  firebase_database: ^10.4.0
// lib/camera_stream_screen.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:firebase_database/firebase_database.dart';

class CameraStreamScreen extends StatefulWidget {
  const CameraStreamScreen({super.key});
  @override
  State<CameraStreamScreen> createState() => _CameraStreamScreenState();
}

class _CameraStreamScreenState extends State<CameraStreamScreen> {
  late DatabaseReference _ref;
  String? _b64Frame;
  int _fps = 0;
  int _frameCount = 0;
  DateTime _lastFpsTime = DateTime.now();

  @override
  void initState() {
    super.initState();
    _ref = FirebaseDatabase.instance.ref('/camera');
    _ref.onValue.listen((event) {
      final data = event.snapshot.value as Map?;
      if (data == null) return;
      final frame = data['frame'] as String?;
      if (frame == null) return;
      _frameCount++;
      final now = DateTime.now();
      if (now.difference(_lastFpsTime).inMilliseconds >= 1000) {
        setState(() { _fps = _frameCount; _frameCount = 0; });
        _lastFpsTime = now;
      }
      setState(() => _b64Frame = frame);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Live Stream  $_fps FPS')),
      body: _b64Frame == null
          ? const Center(child: CircularProgressIndicator())
          : Image.memory(base64Decode(_b64Frame!), gaplessPlayback: true),
    );
  }
}
// Latency note: Expect 500–1500 ms end-to-end latency due to Firebase write → replication → client delivery. For lower latency use direct MJPEG streaming over WebSockets. Firebase works best for remote monitoring where near-real-time (2–3 FPS, 1–2 s delay) is acceptable and simplicity matters more than performance.

Troubleshooting

SymptomLikely CauseFix
Camera init failed (0x105)PSRAM not enabled in IDETools → PSRAM → OPI PSRAM; also ensure AI-Thinker board selected
Firebase write fails every timeIncorrect database URL or auth tokenURL must include https:// prefix; use database secret from Project Settings
Image in viewer is broken/corruptTruncated base64 string due to free-tier string size limitReduce JPEG quality (increase number, e.g. 25) or use QQVGA frame size
Write rate exceeds free quotaFirebase Spark plan: 100K writes/dayIncrease delay between writes to 1000 ms (~1 FPS)
Flutter image flickerssetState rebuilds entire widget treeUse gaplessPlayback: true in Image.memory and keep widget stable

Next Steps

  • Add motion detection using frame differencing (see the related post) and only upload to Firebase when motion is detected — conserves quota dramatically.
  • Switch to Firebase Storage for higher-resolution frames and store them as timestamped event photos.
  • Implement Firebase Authentication so only your Flutter app can read the camera stream.
  • Add a Firebase Cloud Function that triggers on new frame writes and sends a push notification when motion is first detected.
  • For lower latency, replace Firebase with MQTT over WebSockets and binary JPEG payloads.