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.
Top comments (0)