Skip to main content

Mux vs Cloudflare Stream vs Bunny Stream: Video CDN 2026

·PkgPulse Team

Mux vs Cloudflare Stream vs Bunny Stream: Video CDN 2026

TL;DR

Hosting video yourself means encoding pipelines, CDN distribution, adaptive bitrate streaming, and player maintenance. Video hosting APIs abstract this into a simple upload-and-stream workflow. Mux is the developer-first video infrastructure platform — upload a video, get HLS adaptive bitrate streams, thumbnails, GIF previews, captions, and per-second analytics; designed for SaaS products embedding video. Cloudflare Stream is the simplest and often cheapest option — deeply integrated into the Cloudflare network, streams through the same CDN as your other Cloudflare assets, and has a minimal but sufficient API. Bunny Stream is the most affordable at scale — European-based CDN with competitive per-GB pricing, video processing, and a built-in player with DRM options. For developer-first video products with analytics: Mux. For Cloudflare users wanting the simplest integration: Cloudflare Stream. For cost-sensitive applications with high video volume: Bunny Stream.

Key Takeaways

  • Mux pricing: $0.015/minute stored + $0.045/1GB delivered — per-minute storage model
  • Cloudflare Stream: $5/1,000 minutes stored + $1/1,000 minutes viewed — per-view model
  • Bunny Stream: $0.0055/GB CDN + $0.02/minute encoding — lowest cost at high volume
  • Mux Data — per-second video quality analytics (rebuffering, startup time, quality score)
  • Cloudflare Stream uses MP4-to-HLS — automatic adaptive bitrate from any upload
  • Bunny Stream supports DRM — Widevine + FairPlay for content protection
  • All three support direct browser upload — no server required for file upload

Use Cases

SaaS with video + analytics (like Loom, Wistia)  → Mux (Mux Data analytics)
Already on Cloudflare (CDN, Workers, Pages)       → Cloudflare Stream
High-volume VOD on a budget                       → Bunny Stream
DRM content protection                            → Bunny Stream or Mux
Low-latency live streaming                        → Mux (sub-3s RTMP)
Simple video embeds with no analytics             → Cloudflare Stream
European data residency                           → Bunny Stream (EU-based)

Mux: Developer-First Video Infrastructure

Mux is the video platform designed for SaaS developers — upload via URL or direct, get playback URLs, thumbnails, GIF previews, captions, and detailed viewer analytics.

Installation

npm install @mux/mux-node         # Server SDK
npm install @mux/mux-player-react  # Video player component

Upload Video from URL

import Mux from "@mux/mux-node";

const mux = new Mux({
  tokenId: process.env.MUX_TOKEN_ID!,
  tokenSecret: process.env.MUX_TOKEN_SECRET!,
});

// Create an asset from a URL (Mux downloads and processes it)
async function uploadVideoFromUrl(videoUrl: string): Promise<string> {
  const asset = await mux.video.assets.create({
    input: [{ url: videoUrl }],
    playback_policy: ["public"],
    mp4_support: "capped-1080p",    // Also generate MP4 for download
    encoding_tier: "baseline",      // "baseline" | "smart"
    video_quality: "plus",
    master_access: "none",
  });

  console.log("Asset ID:", asset.id);
  console.log("Playback ID:", asset.playback_ids?.[0]?.id);

  return asset.id;
}

// Poll for asset status (or use webhooks)
async function waitForAssetReady(assetId: string): Promise<Mux.Video.Asset> {
  let asset = await mux.video.assets.retrieve(assetId);

  while (asset.status === "preparing") {
    await new Promise((resolve) => setTimeout(resolve, 2000));
    asset = await mux.video.assets.retrieve(assetId);
  }

  return asset;
}

Direct Browser Upload

// Server: Create an upload URL
// app/api/mux/upload/route.ts
export async function POST() {
  const upload = await mux.video.uploads.create({
    new_asset_settings: {
      playback_policy: ["public"],
      encoding_tier: "baseline",
    },
    cors_origin: process.env.NEXT_PUBLIC_APP_URL!,
  });

  return Response.json({
    uploadId: upload.id,
    uploadUrl: upload.url,   // PUT video bytes directly to this URL
  });
}
// Client: Upload file directly to Mux
import * as UpChunk from "@mux/upchunk";

async function uploadVideo(file: File) {
  const { uploadUrl } = await fetch("/api/mux/upload", { method: "POST" }).then((r) => r.json());

  const upload = UpChunk.createUpload({
    endpoint: uploadUrl,
    file,
    chunkSize: 30720,  // 30MB chunks
  });

  upload.on("progress", (progress) => {
    setUploadProgress(progress.detail);
  });

  upload.on("success", () => {
    console.log("Upload complete!");
  });

  upload.on("error", (error) => {
    console.error("Upload error:", error.detail);
  });
}

Mux Player (React)

import MuxPlayer from "@mux/mux-player-react";

function VideoPlayer({ playbackId }: { playbackId: string }) {
  return (
    <MuxPlayer
      playbackId={playbackId}
      metadata={{
        video_title: "My Video",
        viewer_user_id: "user-123",   // For Mux Data viewer analytics
      }}
      streamType="on-demand"
      autoPlay={false}
      muted={false}
      poster={`https://image.mux.com/${playbackId}/thumbnail.jpg?time=10`}
      style={{ width: "100%", aspectRatio: "16/9" }}
    />
  );
}

// Thumbnail URL patterns
// https://image.mux.com/{PLAYBACK_ID}/thumbnail.jpg?time=10    (still at 10s)
// https://image.mux.com/{PLAYBACK_ID}/animated.gif?start=10&end=15  (animated GIF)
// https://image.mux.com/{PLAYBACK_ID}/storyboard.vtt  (seeking preview sprites)

Webhooks

// app/api/mux/webhook/route.ts
import { headers } from "next/headers";
import Mux from "@mux/mux-node";

const mux = new Mux({ tokenId: process.env.MUX_TOKEN_ID!, tokenSecret: process.env.MUX_TOKEN_SECRET! });

export async function POST(req: Request) {
  const body = await req.text();
  const headersList = await headers();
  const signature = headersList.get("Mux-Signature") ?? "";

  // Verify webhook signature
  mux.webhooks.verifySignature(body, { "mux-signature": signature }, process.env.MUX_WEBHOOK_SECRET!);

  const event = JSON.parse(body);

  switch (event.type) {
    case "video.asset.ready": {
      const asset = event.data;
      await db.video.update({
        where: { muxAssetId: asset.id },
        data: {
          status: "ready",
          playbackId: asset.playback_ids?.[0]?.id,
          duration: asset.duration,
        },
      });
      break;
    }
    case "video.asset.errored": {
      await db.video.update({
        where: { muxAssetId: event.data.id },
        data: { status: "error" },
      });
      break;
    }
  }

  return Response.json({ received: true });
}

Signed Playback (Private Videos)

// Create signed playback token for private assets
import jwt from "jsonwebtoken";

function createSignedPlaybackToken(playbackId: string, keyId: string, privateKey: string): string {
  const now = Math.floor(Date.now() / 1000);

  return jwt.sign(
    {
      sub: playbackId,
      aud: "v",              // "v" = video, "t" = thumbnail, "g" = gif, "s" = storyboard
      exp: now + 3600,       // 1 hour expiry
      kid: keyId,
    },
    privateKey,
    { algorithm: "RS256" }
  );
}

// Use in player
const token = createSignedPlaybackToken(playbackId, keyId, privateKey);
// https://stream.mux.com/{PLAYBACK_ID}.m3u8?token={TOKEN}

Cloudflare Stream: Simplest Video API

Cloudflare Stream is the lowest-friction video API — especially for teams already on Cloudflare. Upload a video, embed it, done.

Direct Upload from Browser

// Server: Get a one-time upload URL
async function createStreamUploadUrl(): Promise<{ uploadUrl: string; uid: string }> {
  const response = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/stream/direct_upload`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.CF_API_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        maxDurationSeconds: 3600,
        requireSignedURLs: false,
        meta: { name: "User Upload" },
      }),
    }
  );

  const data = await response.json();
  return { uploadUrl: data.result.uploadURL, uid: data.result.uid };
}
// Client: Upload directly to Cloudflare
async function uploadToCloudflare(file: File) {
  const { uploadUrl, uid } = await fetch("/api/stream/upload", { method: "POST" }).then((r) => r.json());

  // Direct TUS upload
  const upload = new tus.Upload(file, {
    endpoint: uploadUrl,
    chunkSize: 50 * 1024 * 1024,  // 50MB chunks
    onProgress: (uploaded, total) => {
      setProgress(Math.round((uploaded / total) * 100));
    },
    onSuccess: () => {
      console.log("Upload complete! Video UID:", uid);
      // Poll: https://api.cloudflare.com/.../{uid} until readyToStream: true
    },
  });

  upload.start();
}

Embed Player

// Cloudflare Stream iframe embed
function CloudflarePlayer({ videoId }: { videoId: string }) {
  return (
    <div style={{ position: "relative", paddingTop: "56.25%" }}>
      <iframe
        src={`https://customer-${process.env.NEXT_PUBLIC_CF_CUSTOMER_CODE}.cloudflarestream.com/${videoId}/iframe`}
        allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
        allowFullScreen
        style={{ border: "none", position: "absolute", top: 0, left: 0, width: "100%", height: "100%" }}
      />
    </div>
  );
}

List and Manage Videos

async function listVideos(accountId: string, apiToken: string) {
  const response = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${accountId}/stream`,
    { headers: { Authorization: `Bearer ${apiToken}` } }
  );

  const data = await response.json();
  return data.result;  // Array of video objects
}

async function deleteVideo(videoId: string) {
  await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/stream/${videoId}`,
    {
      method: "DELETE",
      headers: { Authorization: `Bearer ${process.env.CF_API_TOKEN}` },
    }
  );
}

Bunny Stream: Cost-Efficient Video CDN

Bunny Stream offers competitive pricing with global CDN, video processing, and optional DRM — popular for high-volume content delivery.

Upload and Manage via REST API

const BUNNY_API_KEY = process.env.BUNNY_API_KEY!;
const BUNNY_LIBRARY_ID = process.env.BUNNY_LIBRARY_ID!;

// Create a video entry
async function createBunnyVideo(title: string): Promise<{ videoId: string }> {
  const response = await fetch(
    `https://video.bunnycdn.com/library/${BUNNY_LIBRARY_ID}/videos`,
    {
      method: "POST",
      headers: {
        AccessKey: BUNNY_API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ title }),
    }
  );

  const data = await response.json();
  return { videoId: data.guid };
}

// Upload video bytes
async function uploadBunnyVideo(videoId: string, videoBuffer: Buffer): Promise<void> {
  await fetch(
    `https://video.bunnycdn.com/library/${BUNNY_LIBRARY_ID}/videos/${videoId}`,
    {
      method: "PUT",
      headers: {
        AccessKey: BUNNY_API_KEY,
        "Content-Type": "application/octet-stream",
      },
      body: videoBuffer,
    }
  );
}

// Full upload pipeline
async function uploadVideo(title: string, videoPath: string): Promise<string> {
  const { videoId } = await createBunnyVideo(title);

  const videoBuffer = fs.readFileSync(videoPath);
  await uploadBunnyVideo(videoId, videoBuffer);

  // Playback URL: https://[hostname].b-cdn.net/{videoId}/playlist.m3u8
  return videoId;
}

Embed Player

// Bunny Stream embeds via iframe
function BunnyPlayer({ videoId, libraryId }: { videoId: string; libraryId: string }) {
  return (
    <div style={{ position: "relative", paddingTop: "56.25%" }}>
      <iframe
        src={`https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}?autoplay=false&loop=false&muted=false&preload=true`}
        loading="lazy"
        allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
        allowFullScreen
        style={{ border: "none", position: "absolute", top: 0, left: 0, width: "100%", height: "100%" }}
      />
    </div>
  );
}

Feature Comparison

FeatureMuxCloudflare StreamBunny Stream
Adaptive bitrate✅ HLS✅ HLS✅ HLS
Direct browser upload✅ (TUS)
Custom player✅ Mux Player✅ Iframe/SDK✅ Iframe/SDK
Analytics✅ Mux Data✅ Basic✅ Basic
DRM✅ Widevine/FairPlay
Live streaming✅ RTMP
Signed URLs
Captions/subtitles✅ Auto-generate
Webhook events
Price/min stored$0.015$0.005$0.0011
Price/GB delivered$0.045$0.001*$0.0055
Free tier100 min/month1,000 min/month

*Cloudflare Stream pricing uses minutes viewed model ($1/1k minutes); GB rate is approximate.


When to Use Each

Choose Mux if:

  • Building a SaaS product with video as a core feature (education platform, content creation, video hosting)
  • Per-viewer analytics (quality score, rebuffering rate, startup time) are important
  • Developer experience is a priority — best SDK, best documentation, best DX
  • Automatic captions and thumbnail generation are needed
  • GIF preview generation for hover-to-preview UX

Choose Cloudflare Stream if:

  • You're already a Cloudflare customer (domain, CDN, Workers)
  • Simplest possible integration — minimal API surface
  • Free 1,000 minutes/month is sufficient for testing or low-volume use
  • Cost is the priority for low-volume use cases (cheapest per-minute storage)

Choose Bunny Stream if:

  • High-volume video delivery where per-GB CDN cost matters most
  • European data residency required (Bunny.net is EU-based)
  • DRM is needed without enterprise pricing
  • Budget-conscious — Bunny is consistently the cheapest option at scale

Methodology

Data sourced from official Mux documentation (docs.mux.com), Cloudflare Stream documentation (developers.cloudflare.com/stream), Bunny Stream documentation (docs.bunny.net/reference/api-overview), pricing pages as of February 2026, and community discussions from the Mux Discord and r/webdev.


Related: Fluent FFmpeg vs ffmpeg-wasm vs node-video-lib for self-hosted video processing before uploading, or Remotion vs Motion Canvas vs Revideo for programmatically generating video content to host on these platforms.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.