Serving video and audio from object storage: range requests

For a browser to play and seek a video straight from storage, the server must answer the Range header with 206 Partial Content and advertise Accept-Ranges: bytes. Without that, the video downloads whole and seeking fails.

Updated 6 min read

Learning objectives

After reading this article you will be able to:

  • Understand how Range, 206 and Content-Range make seeking possible
  • Test with curl whether your storage or server accepts range requests
  • Fix the common errors: moov at the end of an MP4, 200 instead of 206 and caching
On this page

You upload an 800 MB MP4 to storage, drop the URL into a <video> tag and hit play. It works. Then a viewer drags the seek bar to minute 40 and the video stalls, restarts from zero or downloads everything first. Most of the time the file is fine: the server just isn't answering range requests.

The protocol details below follow RFC 9110 and the MDN documentation.

BrowserStorageGET Range: bytes=0-206 Content-Range: bytes 0-…/838860800GET Range: bytes=524288000-206 Content-Range: bytes 524288000-…viewer seekslesson.mp4only the requested ranges are downloaded
Every seek becomes a new GET with Range; storage answers 206 with only that range, without downloading the whole file.

What is a range request?#

It is an HTTP request that asks for only a piece of a file, measured in bytes. The client sends a Range header, and the server returns just that slice:

http
GET /videos/lesson.mp4 HTTP/1.1
Host: example.com
Range: bytes=1048576-2097151
http
HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Type: video/mp4
Content-Length: 1048576
Content-Range: bytes 1048576-2097151/838860800

The four pieces that matter:

  • Range: bytes=start-end asks for byte start through end, both inclusive. bytes=0-1023 is the first 1024 bytes. bytes=1000- runs to the end. bytes=-500 asks for the last 500 bytes.
  • 206 Partial Content means the body is only part of the file.
  • Content-Range: bytes 1048576-2097151/838860800 says which slice arrived and the total size.
  • Accept-Ranges: bytes advertises that the server accepts ranges. If the header is missing, the client cannot count on that support.

If the server ignores Range, it answers 200 OK with the whole file. The browser accepts that, but loses the ability to seek without downloading everything.

Why do video and audio depend on it?#

The <video> and <audio> tags do not download a file completely before playing. The browser requests the start, learns the duration and format, and plays while it downloads. When the viewer seeks, it computes the matching byte and fires a new request with Range.

The preload attribute controls how much is downloaded before play: none downloads nothing, metadata downloads only what is needed (duration and dimensions) and auto lets the browser download the whole file. It is a hint, and the browser may ignore it. For pages listing many videos, preload="metadata" avoids downloading dozens of files nobody will play:

html
<video controls preload="metadata" width="720" src="https://example.com/videos/lesson.mp4"></video>

Without range support you get the scenario from the opening: a seek bar that doesn't respond, a slow start and far more bandwidth than needed.

How do I test whether the server accepts ranges?#

Request the first bytes with curl and look only at the headers:

bash
curl -s -o /dev/null -D - -H "Range: bytes=0-99" https://example.com/videos/lesson.mp4

A correct response looks like this:

http
HTTP/2 206
accept-ranges: bytes
content-length: 100
content-range: bytes 0-99/838860800
content-type: video/mp4

Check three things: the status is 206 (not 200), content-length is 100 and content-range carries the total. If you get 200 with the full file's content-length, the server ignored Range.

The S3 API works with ranges too: GetObject accepts a Range parameter and returns only that slice. In Python with boto3:

python
import os

import boto3

s3 = boto3.client(
    "s3",
    endpoint_url=os.environ.get("S3_ENDPOINT"),  # omit on AWS S3
    aws_access_key_id=os.environ["S3_ACCESS_KEY"],
    aws_secret_access_key=os.environ["S3_SECRET_KEY"],
)

r = s3.get_object(Bucket="my-bucket", Key="videos/lesson.mp4", Range="bytes=0-99")
print(r["ResponseMetadata"]["HTTPStatusCode"])  # 206
print(r["ContentRange"])                        # bytes 0-99/838860800
print(len(r["Body"].read()))                    # 100

How do I serve video from a private bucket?#

If the file is not public, generate a presigned URL and use it as the src. By default an S3 presigned URL signs only the host header, so Range is outside the signature and the browser can ask for any slice with the same URL:

python
url = s3.generate_presigned_url(
    "get_object",
    Params={"Bucket": "my-bucket", "Key": "videos/lesson.mp4"},
    ExpiresIn=6 * 3600,
)

Pick ExpiresIn based on how long playback can last, not just on page load: if the URL expires mid-video, the next seek fails with 403. Generate the URL when the viewer opens the player, not at site build time.

How do I serve ranges from my own server?#

If the file sits on your application's disk (or you proxy it), you have to implement Range yourself. This dependency-free Node.js server handles the start-end, start- and -suffix forms:

js
import { createReadStream, statSync } from "node:fs";
import { createServer } from "node:http";

const FILE = "video.mp4";

createServer((req, res) => {
  const { size } = statSync(FILE);
  const base = { "Content-Type": "video/mp4", "Accept-Ranges": "bytes" };
  const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "");

  if (!m || (m[1] === "" && m[2] === "")) {
    res.writeHead(200, { ...base, "Content-Length": size });
    return createReadStream(FILE).pipe(res);
  }

  let start, end;
  if (m[1] === "") {
    start = Math.max(size - Number(m[2]), 0); // bytes=-500: last 500
    end = size - 1;
  } else {
    start = Number(m[1]);
    end = m[2] === "" ? size - 1 : Math.min(Number(m[2]), size - 1);
  }

  if (start >= size || start > end) {
    res.writeHead(416, { "Content-Range": `bytes */${size}` });
    return res.end();
  }

  res.writeHead(206, {
    ...base,
    "Content-Range": `bytes ${start}-${end}/${size}`,
    "Content-Length": end - start + 1,
  });
  createReadStream(FILE, { start, end }).pipe(res);
}).listen(3000);

Test it with the curl command above, pointing at http://localhost:3000. The example ignores multiple ranges (bytes=0-50, 100-150) and returns the whole file, which the specification allows. In production, prefer serving the file from storage or from a web server that already implements this, rather than maintaining this code.

Which mistakes should I avoid?#

  • moov at the end of the MP4. In an MP4, the video index lives in the moov atom. If it is at the end, the browser has to fetch it before playing. Move it to the start without re-encoding: ffmpeg -i input.mp4 -c copy -movflags +faststart output.mp4. The ffmpeg docs warn that this runs a second pass and does not work with fragmented output.
  • Getting 200 instead of 206. Some layer in the path (proxy, compression middleware, serverless function) is dropping Range. Test with curl straight against the storage, then against the final URL, to find where it gets lost.
  • Wrong Content-Type. An MP4 served as application/octet-stream may download instead of playing. Set video/mp4, video/webm or audio/mpeg on the object.
  • Compressing video. Do not apply gzip or brotli to MP4: the file is already compressed, the gain is minimal and a compression layer in the path can disable or break ranges.
  • Presigned URL too short. It expires mid-playback and seeking breaks.
  • Replacing a file under the same URL. If the object changes while the browser holds cached slices, the pieces do not fit together. Use a new name per version (for example lesson-v2.mp4) instead of overwriting.
  • Unwatched bandwidth. Every seek triggers more requests. On storage that charges for outbound traffic, a popular video becomes a cost; read about egress fees before choosing where to host.

When should I use HLS or DASH instead of an MP4?#

An MP4 with ranges covers short and medium videos at a single quality. If you need to switch resolution based on the viewer's connection, or stream live, the path is HLS or DASH: the video is split into small segments and a playlist says which to fetch. The segments are ordinary files, and object storage is a good fit for them. For most sites, starting with an MP4 and faststart is enough.

How to do this on Square Cloud#

Square Cloud's Blob Storage is object storage with an S3-compatible API, and downloads answer range requests with 206, so the player can seek through the video without downloading the whole file. Public files are served by a global edge CDN, and each object supports settings such as Cache-Control and a choice between download and inline display (useful when someone opens the video URL directly in the browser).

For private files, the boto3 code above works with the https://s3-blob.squarecloud.app endpoint, the auto region, the private bucket and the keys from the "S3 access" tab of the dashboard: the SigV4 presigned URL can last up to 7 days. From the dashboard you also get a temporary link of up to 24 hours and a share link of up to 30 days that can be revoked. Files can be up to 10 GB, downloads carry no bandwidth charge, and every plan includes a quota from 5 GB to 250 GB.

Before publishing, run the curl -H "Range: bytes=0-99" test against your object's URL and confirm the 206 and Accept-Ranges. It's the fastest way to check the behavior with your own file.

Explore Blob Storage

Blob Storage

S3-compatible object storage with no egress fees

Learn more

FAQs

Do I need a streaming server to play video on my site?

For a plain MP4 or WebM file, no. The video tag plays the file over HTTP as long as the server or storage answers range requests. Adaptive streaming (HLS or DASH) is only needed if you want to switch quality based on the network.

What does the 416 Range Not Satisfiable error mean?

The server received a Range that does not fit the file, for example a start byte beyond the object size. It answers 416 with Content-Range: bytes */size. It usually means the file changed or the client computed a wrong offset.

Why does the video only start after downloading most of the file?

In an MP4, the metadata lives in the moov atom. If it sits at the end of the file, the browser has to fetch it before playing. Reprocess with ffmpeg -movflags +faststart to move it to the start, without re-encoding.

Does this work with a presigned URL?

Yes. Range is a request header and is not part of an S3 presigned URL's signature. The browser can send Range normally, and the URL only needs to stay valid for the length of playback.

Next articleHow much does object storage cost? Storage, requests and egressObject storage cost adds up three lines: GB stored, requests and data egress. See how each line is billed, a full worked example and the hidden costs.

More in Storage

See all 15 articles