65ms Time to First Frame: How We Built the Fastest HLS Delivery

The video below starts playing in ~65ms — roughly 2 video frames from instant — on any network condition. This post explains how we got there.

The Problem: HLS Has Too Many Round Trips

Standard HLS playback requires a cascade of sequential network requests before the first frame can render. Each one blocks the next:

  1. Master playlist — browser fetches master.m3u8
  2. Variant playlist — player reads master, fetches 1080p.m3u8
  3. Init segment — player reads variant, fetches 1080p_init.mp4
  4. First media segment — player fetches 1080p_0000.m4s

That's 4 sequential round trips before the decoder has anything to work with. On a connection with 50ms RTT, that's 200ms of pure network latency before a single byte of video data reaches the decoder — and that's before download time, JS initialization, or decode time.

Here's what baseline HLS looks like across network conditions:

Network Baseline TTFF
Real (fast connection)859ms
Fast 4G (12 Mbps, 50ms)1,352ms
Slow 4G (4 Mbps, 100ms)2,622ms
3G (1.5 Mbps, 300ms)6,123ms

6 seconds on 3G. Let's fix each bottleneck one at a time.


Optimization 1: Turbo Mode — Inline Playlists as Data URIs

The first two round trips (variant playlist + init segment) can be eliminated entirely. We base64-encode every variant playlist and init segment directly into the master playlist as data: URIs:

#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS

#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",...,
  URI="data:application/vnd.apple.mpegurl;base64,I0VYVE0zVQ..."

#EXT-X-STREAM-INF:BANDWIDTH=3820000,RESOLUTION=2048x1080,AUDIO="audio"
data:application/vnd.apple.mpegurl;base64,I0VYVE0zVQ...

When hls.js parses this master, it already has the full variant playlist and init segment — no additional fetches needed. Two round trips gone.

The init segment gets inlined inside the variant playlist's #EXT-X-MAP tag:

#EXT-X-MAP:URI="data:video/mp4;base64,AAAAHGZ0eXBpc28..."

Server-side code that builds this:

function inlineInitSegment(playlist, dir, initFilename) {
  const initB64 = readFileSync(join(dir, initFilename))
    .toString("base64");
  const dataUri = `data:video/mp4;base64,$${initB64}`;
  return playlist.replace(
    /URI="[^"]*"/g,
    `URI="$${dataUri}"`
  );
}
Network Before After turbo Change
Real859ms403ms-53%
3G6,123ms6,006ms-2%

Big improvement on fast connections (2 fewer RTTs), marginal on slow ones (download time dominates).


Optimization 2: Inline the Master in HTML

The master playlist itself is still a network fetch. We base64-encode it into the HTML page as a JavaScript variable. hls.js loads it from a blob: URL instead of fetching over the network:

<script>
const MASTER_B64 = "I0VYVE0zVQ..."; // turbo master, inlined at build time
const blob = new Blob([atob(MASTER_B64)], {
  type: "application/vnd.apple.mpegurl"
});
hls.loadSource(URL.createObjectURL(blob));
</script>

Now the player has the full manifest (master + all variants + all init segments) the instant JavaScript executes. Zero network fetches for manifest data.

Network Before After inline Change
Real403ms254ms-37%
3G6,006ms5,635ms-6%

Optimization 3: Preload the First Segment

The only network fetch left is the first media segment. We can overlap its download with JavaScript initialization using <link rel="preload">:

<head>
  <link rel="preload" href="/hls.light.min.js" as="script" />
  <link rel="preload" href="/first0.25s/1080p_0000.m4s"
        as="fetch" crossorigin />
  <link rel="preload" href="/first0.25s/audio_0000.m4s"
        as="fetch" crossorigin />
</head>

The browser starts downloading the first segment during HTML parsing, before JavaScript even begins executing. By the time hls.js initializes and requests the segment, it's already in the browser cache.

Network Before After preload Change
Real254ms80ms-69%
3G5,635ms4,772ms-15%

80ms on fast connections. But 3G is still slow — the 2-second first segment (994KB) takes too long to download even with preload.


Optimization 4: 0.25-Second First Segment

The default 2-second first segment is 994KB at 1080p/3.5Mbps. On 3G, that takes ~5 seconds to download. A 0.25-second segment is only 104KB — it downloads 10x faster.

# Encode with short first segment (0.25s, GOP=6)
ffmpeg -i input.mp4 -t 0.25 -an \
  -vf "scale=-2:1080" \
  -c:v libx264 -b:v 3500k -maxrate 3500k -bufsize 7000k \
  -preset veryslow -g 6 -keyint_min 6 -sc_threshold 0 \
  -f hls -hls_time 0.25 -hls_segment_type fmp4 \
  -hls_fmp4_init_filename 1080p_init.mp4 \
  output/1080p.m3u8

GOP=6 (6 frames at 24fps = 0.25s) has negligible compression penalty compared to the standard GOP=12. The rest of the video uses normal 2-second segments.

First Segment Size Real Fast 4G Slow 4G 3G
2.0s994KB66ms603ms1,790ms4,768ms
1.0s379KB58ms183ms549ms1,416ms
0.5s203KB67ms68ms155ms400ms
0.25s104KB65ms68ms76ms73ms
0.125s50KB69ms65ms73ms65ms

At 0.25s, the segment arrives before hls.js is even ready to request it. Going shorter to 0.125s doesn't help — the bottleneck is now JavaScript initialization, not the network.


Optimization 5: hls.js Light Build, Self-Hosted

Two more wins by changing how we load the player library:


Optimization 6: Player Configuration

Two hls.js config options prevent the player from wasting time on startup:

const hls = new Hls({
  startLevel: -1,
  maxBufferLength: 5,
  maxMaxBufferLength: 10,
  startFragPrefetch: true,

  // Skip bandwidth estimation — trust a high default
  testBandwidth: false,
  abrEwmaDefaultEstimate: 10_000_000,

  progressive: true,
});

hls.on(Hls.Events.MANIFEST_PARSED, () => {
  // Force 1080p start — don't let ABR pick 240p first
  const idx = hls.levels.findIndex(l => l.height === 1080);
  if (idx !== -1) hls.startLevel = idx;
  video.play().catch(() => {});
});

testBandwidth: false skips the initial bandwidth probe. abrEwmaDefaultEstimate: 10_000_000 tells the ABR algorithm to assume 10 Mbps, which prevents the cautious 240p start.


The Full Stack

Putting it all together — the cumulative effect of each optimization:

Optimization RTTs Real 3G
Baseline HLS4859ms6,123ms
+ Turbo mode2403ms6,006ms
+ Inline master in HTML1254ms5,635ms
+ Preload first segment~080ms4,772ms
+ 0.25s first segment~065ms73ms
+ hls.js light + self-host~065ms67ms

859ms to 65ms on fast connections. 6,123ms to 67ms on 3G. 92% faster on every network condition.


What We Tested That Didn't Help

We also ran an exhaustive optimization grid testing 7 additional strategies beyond the current best. All measurements: Playwright Chromium headless, 3 runs, cache disabled, median reported.

Strategy No throttle Fast 4G Slow 4G 3G
Current best (control)65ms67ms67ms127ms
fetchpriority="high"72ms70ms60ms126ms
Poster frame (WebP)77ms61ms69ms143ms
Skip audio preload63ms66ms72ms127ms
Video-only bootstrap63ms71ms69ms131ms
103 Early Hints71ms68ms75ms127ms

Everything is within noise of the control. At ~65ms, we've hit the physical floor — the remaining time is HTML parse + hls.js init + MSE setup + video decode + compositor. No network optimization can reduce those.


What This Means in Practice

65ms is 1.5 frames at 24fps. The video appears to start instantly. On 3G — a connection most video platforms consider unwatchable — the experience is identical to a fast fiber connection.

The key insight: eliminate round trips, not bandwidth. Standard HLS's 4-request waterfall is the bottleneck, not the pipe size. Once all manifest data is inlined and the first segment is small enough to preload during JS init, network speed becomes irrelevant.

All measurements and code are in the open source repo.

Logo
X LinkedIn Medium Email