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
moovatom 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 butnone) means the server supports ranges. No header means it doesn't. - Client sends
Range: bytes=0-1023. Server replies206 Partial ContentwithContent-Range: bytes 0-1023/146515. - Out-of-bounds range gets
416. - A server that doesn't support ranges replies
200 OKwith 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 -rw-r--r-- 1 root root 38167326 long_f.mp4 -rw-r--r-- 1 root root 38167326 long_nf.mp4 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 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 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'); node range-server.js ranges 8801 & node range-server.js noranges 8802 & ⚠️ 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 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 # 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 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'); })(); $ 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-"] | 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) 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)] 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 -Iand a rangedcurlon your real media URL, through your CDN. - Run
boxes.pyon a handful of your MP4s and remux any withmoovat the end. - Add the 200-to-206 ratio to a dashboard.
