DEV Community

Mason K
Mason K

Posted on

Byte-range requests for video: build a 206 server, test seeking in Chromium, fix moov placement

TL;DR

Seeking in a plain MP4 works by asking the server for byte ranges. We'll write a 25-line Node server that answers them, show what happens in Chromium when a server ignores them, and fix the other classic seek-killer: the moov atom at the end of the file.

๐Ÿ“ฆ Code: github.com/USER/range-demo (replace before publishing)

Versions used: node 22, python 3.13, ffmpeg 6.1.1, playwright with Chromium 153.0.8010.12. #webdev #tutorial

1. ๐Ÿ“– The protocol in four lines

From MDN's range request guide:

  • Accept-Ranges: bytes (anything but none) means the server supports ranges. No header means it doesn't.
  • Client sends Range: bytes=0-1023. Server replies 206 Partial Content with Content-Range: bytes 0-1023/146515.
  • Out-of-bounds range gets 416.
  • A server that doesn't support ranges replies 200 OK with the whole body.

2. ๐ŸŽฅ Make two test files

# make.sh
ffmpeg -y -f lavfi -i "mandelbrot=size=1280x720:rate=24" -t 40 \
  -c:v libx264 -crf 22 -pix_fmt yuv420p -an long_nf.mp4          # moov at the end (default)
ffmpeg -y -i long_nf.mp4 -c copy -movflags +faststart long_f.mp4  # moov at the front
ls -l long_*.mp4
Enter fullscreen mode Exit fullscreen mode
-rw-r--r-- 1 root root 38167326 long_f.mp4
-rw-r--r-- 1 root root 38167326 long_nf.mp4
Enter fullscreen mode Exit fullscreen mode

Same size, same pixels; only the box order differs.

3. ๐Ÿ’ฅ Break it: Python's static server

python3 -m http.server 8000 &
curl -s -D - -o /dev/null -H 'Range: bytes=0-99' http://127.0.0.1:8000/long_f.mp4
Enter fullscreen mode Exit fullscreen mode
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.13.17
Content-type: video/mp4
Content-Length: 176067
Last-Modified: Mon, 26 Oct 2026 05:06:44 GMT
Enter fullscreen mode Exit fullscreen mode

I ran this against a small clip; the shape is what matters. 200, full Content-Length, no Accept-Ranges. The server ignored the Range header and would have sent the whole file.

4. ๐Ÿ› ๏ธ A server that does it right

// range-server.js  (node 22)
const http = require('http'), fs = require('fs');
const ranges = process.argv[2] !== 'noranges';
const TYPES = { html: 'text/html', jpg: 'image/jpeg', webp: 'image/webp', mp4: 'video/mp4' };

http.createServer((q, r) => {
  const f = '.' + q.url.split('?')[0];
  if (!fs.existsSync(f)) { r.writeHead(404); return r.end(); }
  const size = fs.statSync(f).size, st = fs.statSync(f);
  const etag = '"' + size + '-' + st.mtimeMs + '"';
  const h = { 'Content-Type': TYPES[f.split('.').pop()], ETag: etag };
  if (ranges) h['Accept-Ranges'] = 'bytes';

  const m = ranges && /^bytes=(\d*)-(\d*)$/.exec(q.headers.range || '');
  const ifRange = q.headers['if-range'];
  if (!m || (ifRange && ifRange !== etag)) {          // no/stale range: full 200
    h['Content-Length'] = size; r.writeHead(200, h);
    return q.method === 'HEAD' ? r.end() : fs.createReadStream(f).pipe(r);
  }
  const start = m[1] === '' ? size - +m[2] : +m[1];   // suffix range: last N bytes
  const end = m[1] === '' || m[2] === '' ? size - 1 : Math.min(+m[2], size - 1);
  if (start >= size || start > end) {
    h['Content-Range'] = 'bytes */' + size; r.writeHead(416, h); return r.end();
  }
  h['Content-Range'] = `bytes ${start}-${end}/${size}`;
  h['Content-Length'] = end - start + 1; r.writeHead(206, h);
  q.method === 'HEAD' ? r.end() : fs.createReadStream(f, { start, end }).pipe(r);
}).listen(+process.argv[3] || 8801, '127.0.0.1');
Enter fullscreen mode Exit fullscreen mode
node range-server.js ranges 8801 &
node range-server.js noranges 8802 &
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ Note: this is a teaching server. It does not handle multi-range requests (bytes=0-50, 100-150), and the weak-ETag edge cases are skipped. Use a real server for production.

5. โœ… Check the status codes

curl -s -D - -o /dev/null -H 'Range: bytes=0-99' localhost:8801/fast.mp4 | head -6
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 206 Partial Content
Content-Type: video/mp4
ETag: "176067-1792991222909.9573"
Accept-Ranges: bytes
Content-Range: bytes 0-99/176067
Content-Length: 100
Enter fullscreen mode Exit fullscreen mode
# out of bounds
curl -s -D - -o /dev/null -H 'Range: bytes=999999999-' localhost:8801/fast.mp4 | head -1
HTTP/1.1 416 Range Not Satisfiable

# stale If-Range: server must send the whole thing
curl -s -D - -o /dev/null -H 'Range: bytes=0-9' -H 'If-Range: "stale"' localhost:8801/fast.mp4 | head -1
HTTP/1.1 200 OK

# server without range support
curl -s -D - -o /dev/null -H 'Range: bytes=0-99' localhost:8802/fast.mp4 | head -1
HTTP/1.1 200 OK
Enter fullscreen mode Exit fullscreen mode

If-Range makes a range request conditional (MDN). If the validator no longer matches, the full resource comes back with a 200. That is correct, and it's why stable ETags across your origin fleet matter.

6. ๐Ÿงช Watch Chromium seek

// seek-test.js
const { chromium } = require('playwright');
async function run(port, f) {
  const b = await chromium.launch(); const pg = await b.newPage();
  const reqs = [];
  pg.on('request', r => { if (r.url().endsWith('.mp4')) reqs.push(r.headers()['range'] || '-'); });
  await pg.goto(`http://127.0.0.1:${port}/p_${f}.html`);   // <video id=v src=/f.mp4 preload=auto muted>
  await pg.evaluate(() => new Promise(r => { const v = document.getElementById('v');
    v.addEventListener('loadedmetadata', () => r()); if (v.readyState >= 1) r(); }));
  const res = await pg.evaluate(() => new Promise(r => { const v = document.getElementById('v');
    const k = v.seekable.length ? [v.seekable.start(0), v.seekable.end(0)] : null;
    v.addEventListener('seeked', () => r({ k, cur: v.currentTime }), { once: true });
    v.currentTime = 30; }));
  console.log(port, f, JSON.stringify(res), 'reqs', JSON.stringify(reqs));
  await b.close();
}
(async () => { await run(8801, 'long_f'); await run(8801, 'long_nf'); await run(8802, 'long_f'); })();
Enter fullscreen mode Exit fullscreen mode
$ node seek-test.js
8801 long_f  {"k":[0,40],"cur":30} reqs ["bytes=0-","bytes=12582912-"]
8801 long_nf {"k":[0,40],"cur":30} reqs ["bytes=0-","bytes=38141952-","bytes=12582912-"]
8802 long_f  {"k":[0,0],"cur":0}   reqs ["bytes=0-"]
Enter fullscreen mode Exit fullscreen mode
Server Layout Seekable After currentTime = 30 Requests
ranges faststart 0 to 40 30 2
ranges moov at end 0 to 40 30 3 (one jump to the last bytes)
no ranges faststart 0 to 0 stayed at 0 1

Read the third row twice. Chrome made one request, got a 200, and the seek to 30 s did nothing. That is the production bug, reproduced.

โš ๏ธ Note: I ran this on localhost and I'm not quoting timings; they'd be meaningless. The request pattern is the finding.

7. ๐Ÿ“ฆ Where is moov?

# boxes.py
import struct, sys
d = open(sys.argv[1], 'rb').read(); p = 0; out = []
while p < len(d):
    size, typ = struct.unpack('>I4s', d[p:p+8]); out.append((typ.decode(), p, size)); p += size
print(sys.argv[1], len(d), out)
Enter fullscreen mode Exit fullscreen mode
nofast.mp4 141422 [('ftyp', 0, 32), ('free', 32, 8), ('mdat', 40, 139844), ('moov', 139884, 1538)]
fast.mp4   176067 [('ftyp', 0, 32), ('moov', 32, 1540), ('free', 1572, 8), ('mdat', 1580, 174487)]
Enter fullscreen mode Exit fullscreen mode

Default FFmpeg output has moov at the end (byte 139884 here), so the player needs a ranged request to the tail before it can seek. -movflags +faststart moves it to byte 32. The remux is -c copy, so it's cheap.

๐Ÿ’ก Tip: this only matters when you serve MP4 directly. If you package to HLS or DASH, playlists and init segments take over this role.

8. ๐ŸŒ CDN and service worker notes

  • A vendor blog (BlazingCDN) describes whole-object vs slice caching, and says the origin has to answer partial requests because a CDN cannot slice what the origin won't partial-serve. Its numbers are modelled by its author, so I'm not quoting them; confirm behaviour with your own CDN's docs and logs.
  • Watch the ratio of 200 to 206 on media routes. A rising 200 share means clients are asking for slices and receiving whole files.
  • If you cache video in a service worker, the Range header gets ignored by default and the browser receives the full file. The Workbox range-requests plugin returns a proper 206 from the cached response.

What's next

  • Run curl -I and a ranged curl on your real media URL, through your CDN.
  • Run boxes.py on a handful of your MP4s and remux any with moov at the end.
  • Add the 200-to-206 ratio to a dashboard.

Top comments (0)