Where the MP4 index sits, and what it does to playback
Whisk. Published 5 October 2026. Measurements taken 4 October 2026.
Abstract
An MP4 file keeps its index, the moov box, either before or after the media data. We measured how that position affects the start of playback in a web browser. The same 60-second, 16.0 MB video was served to Chromium 141 in both layouts, from a server that either did or did not support HTTP range requests, at three connection speeds. When the server supported range requests, the index position made little difference: with the index at the end, Chromium made two extra requests and the first frame arrived within 65 ms of the faststart file on a connection without added delay. When we added a fixed delay before each response, standing in for network distance, the index at the end cost 127 to 546 ms more, growing with the delay. When the server did not support range requests, a file with its index at the end could not begin playing until the whole file had downloaded: 98.7 s at 1.3 Mbit/s, against under 1 s for the same file with its index at the start.
1. Introduction
Most video on the web is delivered as MP4 files fetched over HTTP, a method known as progressive download. An MP4 file is made of boxes (also called atoms). The mdat box holds the encoded audio and video, and the moov box holds the index a player needs before it can decode anything: the tracks, the codecs, and where each frame sits in the file [1]. Encoders usually write moov last, because its contents are not known until encoding ends. FFmpeg's documentation notes that the index "is normally written at the end of the file, but it can be moved to the start for better playback" with its faststart option [2].
Discussions of this option often describe its benefit in terms of server work, or state in general terms that a file with its index at the end must be fully downloaded before playback. This paper measures what a current browser actually does in each case, how much time and data each layout costs, and how the answer depends on whether the server supports range requests [3][4].
2. Method
Test files. We generated one 60-second, 1280 × 720, 30 frames per second test video with FFmpeg 6.1.1, encoded as VP9 at about 2.0 Mbit/s with Opus audio at 128 kbit/s, in an MP4 container. The Chromium build we used does not include an H.264 decoder, so we used VP9, which Chromium plays in MP4. The position of the index is a property of the container, not of the codec. We then copied the streams, without re-encoding, into a second file with -movflags +faststart. The two files were byte-for-byte the same size, 16,039,621 bytes. In the first, moov (64,588 bytes) followed mdat. In the second, it preceded mdat.
Server. A small HTTP server (Appendix A) served the files at a fixed rate, writing data in small pieces and pausing between them. It could honour or ignore the Range request header, and it advertised range support with Accept-Ranges: bytes only when it honoured it. It recorded every request, the range asked for, and the bytes sent. We tested three rates: 160 KiB/s (about 1.3 Mbit/s, slower than the video's bitrate), 512 KiB/s (about 4.2 Mbit/s) and 2048 KiB/s (about 16.8 Mbit/s). Server and browser ran on the same machine, so there was no network delay unless we added one.
Browser. Chromium 141.0.7390.37, driven headless by Playwright, loaded a page with a muted <video> element (Appendix B) and set its source. We recorded the time from setting the source to the loadeddata event, which the HTML standard fires when the first frame is available [5], and to the playing event. We also recorded how many bytes the server had sent when the first frame arrived. Each browser run started a fresh browser context, and each file name carried a unique query string so that nothing came from cache.
Conditions. Every combination of index position (start, end), range support (on, off) and rate (three) was run five times, 60 runs in all. To approximate network distance, we then repeated the range-supported case at 512 KiB/s with a fixed delay of 50, 150 or 300 ms added before each video response, five runs each.
3. Results
3.1 Range requests supported
| Rate | Index at start: first frame | Index at end: first frame | Index at end: requests | Bytes sent by first frame (start / end) |
|---|---|---|---|---|
| 160 KiB/s | 978 ms | 1,043 ms | 3 | 164 KB / 188 KB |
| 512 KiB/s | 330 ms | 359 ms | 3 | 183 KB / 239 KB |
| 2048 KiB/s | 137 ms | 110 ms | 3 | 419 KB / 501 KB |
Medians of five runs. The slowest and fastest runs were within 32 ms of the median in every row.
With the index at the start, Chromium made one request (Range: bytes=0-) and played from it. With the index at the end, it made three requests in every run: it started reading from the beginning, found the media data instead of the index, requested the end of the file (bytes=15958016-) to read the index, then went back to the media data (bytes=32768-). On a connection without added delay this cost at most 65 ms, and at the fastest rate the file with its index at the end reached its first frame slightly sooner, probably because it began fetching media data while the index was still arriving.
3.2 Range requests supported, with added delay
| Delay before each response | Index at start | Index at end | Difference |
|---|---|---|---|
| 50 ms | 387 ms | 514 ms | 127 ms |
| 150 ms | 482 ms | 825 ms | 343 ms |
| 300 ms | 726 ms | 1,272 ms | 546 ms |
Medians of five runs at 512 KiB/s.
Because the file with its index at the end needs two extra requests before playback, each delay is paid up to three times instead of once. The cost grew with the delay, at roughly twice the delay itself.
3.3 Range requests not supported
| Rate | Index at start: first frame | Index at end: first frame | Bytes sent by first frame (index at end) |
|---|---|---|---|
| 160 KiB/s | 979 ms | 98,709 ms | 16,039,621 (the whole file) |
| 512 KiB/s | 346 ms | 30,919 ms | 16,039,621 (the whole file) |
| 2048 KiB/s | 142 ms | 7,809 ms | 16,039,621 (the whole file) |
Medians of five runs.
With the index at the start, ignoring range requests made no measurable difference. With the index at the end, Chromium could not ask for the end of the file, so it read the file from the beginning until it reached the index. The first frame arrived only after the entire file had been downloaded, in every run, and the wait matched the time to transfer 16 MB at each rate. At 160 KiB/s that was over a minute and a half for a one-minute video, against under a second for the same file with its index at the start. No run failed. Playback started eventually in every case.
4. Discussion
The position of the MP4 index matters to the browser more than to the server. Its effect depends on the server.
When a server supports range requests, as most web servers and object storage services do for static files, a browser can find an index at the end of the file and start playing quickly. The cost is two extra round trips. On a nearby server that is negligible. On a distant one, or a slow mobile connection where each round trip takes hundreds of milliseconds, it adds a noticeable pause before playback.
When a server does not support range requests, the position of the index decides whether playback starts at once or after the whole file has arrived. This happens more often than it might seem: video served by application code rather than a static file server, for example from a database, through a proxy, or by a framework route that returns the whole file, often ignores the Range header unless someone has written code to handle it. For a long video, waiting for the full download makes it effectively unplayable.
Moving the index to the start costs nothing: it is a copy of the streams with -movflags +faststart, it does not change the file's size or quality, and every result in this study was the same or better for the faststart file except one median at the fastest rate, which was 27 ms slower. Files intended for playback in a browser should be written with their index first. Whether a file already has its index first can be checked with ffprobe -v trace -i video.mp4 2>&1 | grep -oE "type:'(moov|mdat)' parent:'root'" | head -2, which prints the two boxes in the order they appear.
5. Limitations
We tested one browser engine, Chromium, in one version. Firefox and Safari use different media stacks and may request ranges differently. We used VP9 because this Chromium build lacks H.264, and one file of one length; the full-download wait in Section 3.3 grows with file size. Server and browser ran on one machine, so connection speed was controlled by the server's pacing and network distance by a fixed delay before each response, which only approximates real network conditions such as packet loss and slow start. Each condition was run five times.
6. Conclusion
In Chromium, an MP4 file with its index at the end plays almost as quickly as a faststart file when the server supports range requests, at a cost of two extra round trips. When the server does not support range requests, the same file cannot start until it has fully downloaded, which in our tests took up to 98.7 s for a one-minute video. Writing the index first removes both costs.
For related measurements, see our papers on files in an in-memory /tmp and container memory limits and on how late visitor-triggered scheduled tasks run.
References
- ISO/IEC 14496-12. Information technology: Coding of audio-visual objects, Part 12: ISO base media file format. International Organization for Standardization.
- FFmpeg developers. "MOV/MPEG-4/ISOMBFF muxers." FFmpeg Formats Documentation. https://ffmpeg.org/ffmpeg-formats.html#MOV002fMPEG002d4_002fISOMBFF-muxers (accessed 4 October 2026).
- R. Fielding, M. Nottingham, J. Reschke. "HTTP Semantics," RFC 9110, section 14 (Range Requests). IETF, 2022. https://www.rfc-editor.org/rfc/rfc9110#section-14
- Mozilla. "HTTP range requests." MDN Web Docs. https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests (accessed 4 October 2026).
- WHATWG. "Media elements: Event summary." HTML Living Standard. https://html.spec.whatwg.org/multipage/media.html#mediaevents (accessed 4 October 2026).
Appendix A: Test server
Run with python3 server.py 9101 on 512 (port, range support, KiB per second, and an optional delay in milliseconds before each response).
import http.server, json, os, re, sys, threading, time
PORT, RANGES, RATE = int(sys.argv[1]), sys.argv[2] == "on", int(sys.argv[3]) * 1024
DELAY = int(sys.argv[4]) / 1000 if len(sys.argv) > 4 else 0
lock = threading.Lock()
stats = {"sent": 0, "requests": []}
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a): pass
def do_GET(self):
if self.path == "/__reset":
with lock: stats["sent"] = 0; stats["requests"] = []
return self.reply(200, b"ok", "text/plain")
if self.path == "/__stats":
with lock: body = json.dumps(stats).encode()
return self.reply(200, body, "application/json")
if self.path.startswith("/page"):
return self.reply(200, open("page.html", "rb").read(), "text/html")
time.sleep(DELAY)
name = os.path.basename(self.path.split("?")[0])
if not os.path.isfile(name): return self.reply(404, b"", "text/plain")
data = open(name, "rb").read()
start, end, status = 0, len(data) - 1, 200
m = re.match(r"bytes=(\d*)-(\d*)", self.headers.get("Range", ""))
if RANGES and m:
if m.group(1): start = int(m.group(1)); end = int(m.group(2)) if m.group(2) else end
else: start = len(data) - int(m.group(2))
end = min(end, len(data) - 1); status = 206
rec = {"t": time.time(), "range": self.headers.get("Range"), "status": status, "start": start, "end": end, "sent": 0}
with lock: stats["requests"].append(rec)
self.send_response(status)
self.send_header("Content-Type", "video/mp4")
self.send_header("Content-Length", str(end - start + 1))
if RANGES: self.send_header("Accept-Ranges", "bytes")
if status == 206: self.send_header("Content-Range", f"bytes {start}-{end}/{len(data)}")
self.end_headers()
pos, chunk = start, max(1024, RATE // 20)
try:
while pos <= end:
piece = data[pos:min(pos + chunk, end + 1)]
self.wfile.write(piece); pos += len(piece)
with lock: stats["sent"] += len(piece); rec["sent"] += len(piece)
time.sleep(len(piece) / RATE)
except (BrokenPipeError, ConnectionResetError):
pass
def reply(self, code, body, ctype):
self.send_response(code); self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
http.server.ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()Appendix B: Test page and measurement
page.html, served by the test server at /page:
<!doctype html><video id="v" muted playsinline preload="auto"></video>
<script>
const v = document.getElementById('v'); window.marks = {};
for (const e of ['loadedmetadata','loadeddata','canplay','playing','error'])
v.addEventListener(e, () => { window.marks[e] ??= performance.now() - window.t0; });
window.start = (src) => { window.t0 = performance.now(); v.src = src; v.play().catch(()=>{}); };
</script>measure.mjs, run with node measure.mjs 9101 end.mp4 label:
import { chromium } from 'playwright';
const [port, file, label] = process.argv.slice(2);
const base = `http://127.0.0.1:${port}`;
const browser = await chromium.launch();
const page = await (await browser.newContext()).newPage();
await page.goto(`${base}/page`);
await fetch(`${base}/__reset`);
await page.evaluate((src) => window.start(src), `${base}/${file}?${Date.now()}`);
let marks = {}, bytesAtFirstFrame = null, bytesAtPlaying = null;
const deadline = Date.now() + 180000;
while (Date.now() < deadline) {
marks = await page.evaluate(() => window.marks);
const s = await (await fetch(`${base}/__stats`)).json();
if (marks.loadeddata != null && bytesAtFirstFrame == null) bytesAtFirstFrame = s.sent;
if (marks.playing != null && bytesAtPlaying == null) bytesAtPlaying = s.sent;
if ((marks.playing != null && bytesAtPlaying != null) || marks.error != null) break;
await new Promise(r => setTimeout(r, 50));
}
const s = await (await fetch(`${base}/__stats`)).json();
console.log(JSON.stringify({ label, file, marks, bytesAtFirstFrame, bytesAtPlaying, requests: s.requests }));
await browser.close();The test files were made with:
ffmpeg -f lavfi -i testsrc2=size=1280x720:rate=30:duration=60 -f lavfi -i sine=frequency=440:duration=60 \ -c:v libvpx-vp9 -deadline realtime -cpu-used 8 -b:v 2000k -minrate 2000k -maxrate 2000k -g 60 \ -c:a libopus -b:a 128k end.mp4 ffmpeg -i end.mp4 -c copy -movflags +faststart start.mp4