Loading...
Loading...
Netflix-style ABR video, dashboard virtualization, Web Workers, and realtime charts.
Module 23 · Media & Data Dashboards
Adaptive bitrate video (HLS/DASH), dashboard virtualization, Web Workers, real-time charts, and performance budgets for media-heavy UIs.
Adaptive bitrate video (HLS/DASH), dashboard table virtualization, Web Workers for heavy compute, real-time charts, and performance budgets.
Media and analytics dashboards push frontend limits — ABR streaming, 100k-row tables, real-time charts, and heavy compute offloaded to Web Workers.
This module covers HLS/DASH players, @tanstack/react-virtual, worker-based aggregation, canvas chart downsampling, and strict performance budgets.
Staff capstone: design a Netflix-style player alongside a real-time analytics dashboard.
Staff track module — builds on Web Performance (Module 10) and JavaScript Internals (Module 1).
Media and analytics surfaces are staff capstones — combining ABR video, 100k-row virtualization, and realtime charts without blocking the main thread.
Video ABR, CDN delivery, virtualization, and Web Workers.
Media-heavy and data-dense UIs stress the main thread. Staff engineers design adaptive bitrate streaming, CDN strategies, virtualized dashboards, and Web Worker offloading for 60fps at scale.
Both problems are the same problem: more data arrives than a 16.7ms frame can absorb. Video solves it by choosing a lower-quality representation; dashboards solve it by rendering less of the data. Everything below is a variation on that.
| Surface | Hard budget | What breaks past it |
|---|---|---|
| Video startup | < 2s to first frame (p75) | Abandonment climbs ~6% per extra second |
| Rebuffer ratio | < 0.5% of playback time | Session drop-off and quality complaints |
| Frame budget | 16.7ms; scripting < 8ms | Dropped frames, janky scroll |
| Dashboard TTI | < 3s on mid-tier Android | Users interact before handlers attach |
| Realtime update | < 1 paint per rAF, ≤ 60/s | INP > 500ms and pegged CPU |
| Worker payload | < 50MB per postMessage | Structured clone stalls the main thread |
Ownership boundary
HLS, DASH, and quality ladder selection.
| Protocol | Format | Browser support |
|---|---|---|
| HLS | .m3u8 segments | Safari native; hls.js elsewhere |
| DASH | .mpd manifest | MSE + dash.js |
| CMAF | Unified fMP4 segments | Modern players both protocols |
| LL-HLS | Partial segments, ~2s glass-to-glass | hls.js + origin support |
# Multivariant HLS manifest — a real bitrate ladder with CMAF fMP4 segments. #EXTM3U #EXT-X-VERSION:7 #EXT-X-STREAM-INF:BANDWIDTH=400000,RESOLUTION=426x240,CODECS="avc1.42c015,mp4a.40.2" 240p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.4d401e,mp4a.40.2" 360p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720,CODECS="avc1.4d401f,mp4a.40.2" 720p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2" 1080p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=9000000,RESOLUTION=1920x1080,CODECS="hvc1.2.4.L123.B0" 1080p-hevc/index.m3u8
// Hybrid ABR: throughput estimate gated by buffer occupancy. // Pure throughput oscillates on mobile; pure buffer ramps up too slowly. const SAFETY = 0.75; // never plan for more than 75% of measured bandwidth const PANIC_S = 4; // below this much buffer, drop immediately const RAMP_S = 18; // above this, allow one step up export function chooseRendition(s: PlayerState, ladder: Rendition[]): Rendition { if (s.bufferedSeconds < PANIC_S) { return ladder[0]; // stall costs more than pixels } const budget = s.throughputBps * SAFETY; const affordable = ladder.filter((r) => r.bitrate <= budget); const best = affordable.at(-1) ?? ladder[0]; // Step up at most one rung, and only with a healthy buffer. const currentIndex = ladder.indexOf(s.current); const bestIndex = ladder.indexOf(best); if (bestIndex > currentIndex) { return s.bufferedSeconds > RAMP_S ? ladder[currentIndex + 1] : s.current; } return best; // downgrades apply immediately }
| Failure | What the user sees | Mitigation |
|---|---|---|
| Segment 404 mid-playback | Playback freezes at a frame | Retry other CDN, then drop a rung |
| Bandwidth cliff in a tunnel | Spinner, then stall | Panic downgrade below 4s buffer |
| Ladder oscillation | Quality flickers every few seconds | Asymmetric switching, hysteresis |
| DRM licence expiry | Black screen, cryptic error | Renew before expiry, surface a real message |
| Codec unsupported | Audio only, or nothing | Capability probe before selecting the variant |
Cost
Segments, thumbnails, and signed URLs.
| Asset | TTL | Cache key | Why |
|---|---|---|---|
| Media segment (.m4s) | 1 year, immutable | Path only | Content-addressed, never mutates |
| Multivariant manifest | 60s | Path + device class | Ladder changes on re-encode |
| Live media playlist | 1–2s | Path | Must track the live edge |
| Thumbnail sprite | 1 year | Path + hash | Regenerated per encode |
| Licence / signed URL | No store | Per user | Entitlement is user-specific |
// Multi-CDN with per-CDN health, not blind round-robin. const CDNS = ["https://cdn-a.example", "https://cdn-b.example"]; export async function fetchSegment(path: string, attempt = 0): Promise<ArrayBuffer> { const cdn = CDNS[scoreboard.best()]; // ranked by recent error + latency const started = performance.now(); try { const res = await fetch(`${cdn}${path}`, { signal: AbortSignal.timeout(6_000) }); if (!res.ok) throw new Error(`segment_${res.status}`); scoreboard.success(cdn, performance.now() - started); return res.arrayBuffer(); } catch (err) { scoreboard.failure(cdn); if (attempt < CDNS.length - 1) return fetchSegment(path, attempt + 1); throw err; // player drops a rung and retries } }
Staff depth
Custom controls, captions, and analytics.
The player is the most expensive component most pages import. hls.js is roughly 130KB gzip, dash.js closer to 200KB, and a full commercial SDK can exceed 400KB. A facade — poster image plus a play button — defers all of it until intent is expressed, which typically removes 300–500ms of main-thread work from LCP.
// Facade: zero player bytes until the user commits. export function VideoFacade({ poster, src }: { poster: string; src: string }) { const [active, setActive] = useState(false); if (!active) { return ( <button type="button" onClick={() => setActive(true)} aria-label="Play video"> <img src={poster} alt="" width={1280} height={720} loading="lazy" /> </button> ); } return <LazyPlayer src={src} autoPlay />; // dynamic import, ssr: false } // QoE: one event stream the media team can actually act on. const qoe = { startedAt: performance.now(), rebuffers: 0, stalledMs: 0 }; video.addEventListener("waiting", () => { qoe.rebuffers += 1; qoe.stallStart = performance.now(); }); video.addEventListener("playing", () => { if (qoe.stallStart) qoe.stalledMs += performance.now() - qoe.stallStart; qoe.stallStart = undefined; }); video.addEventListener("loadeddata", () => rum.track("video_startup", { ms: performance.now() - qoe.startedAt, cdn, rendition }));
| Approach | Bundle cost | Control | Use when |
|---|---|---|---|
| Native <video> + HLS (Safari) | 0KB | Low | Simple VOD, Apple platforms |
| hls.js | ~130KB gzip | High | HLS everywhere, custom ABR |
| dash.js / Shaka | ~200KB gzip | High | DASH, widevine DRM |
| Commercial SDK | 300–450KB | Highest, with support | DRM, ads, analytics bundled |
| Third-party embed | ~0 first-party | None | Marketing pages only |
10k rows without 10k DOM nodes.
| Library | Best for | Bundle | Note |
|---|---|---|---|
| TanStack Virtual | Tables + grids | ~6KB gzip | Headless, flexible, variable height |
| react-window | Fixed-size lists | ~2KB gzip | Lightweight, minimal API |
| AG Grid | Enterprise tables | ~200KB+ | Pivot, grouping, export built in |
| CSS content-visibility | Long documents | 0KB | No JS, but no windowing of data |
| Canvas grid | 100k+ cells at 60fps | Custom | You re-implement selection and a11y |
// 10,000 rows: ~24 in the DOM instead of 10,000. // Node count is the cost driver — 10k rows x 8 cells is 80k elements and ~250MB. const ROW = 36; // px, fixed height keeps the maths O(1) const OVERSCAN = 6; // rows above and below the viewport function useWindow(scrollTop: number, viewportH: number, total: number) { const first = Math.max(0, Math.floor(scrollTop / ROW) - OVERSCAN); const visible = Math.ceil(viewportH / ROW) + OVERSCAN * 2; const last = Math.min(total, first + visible); return { first, last, padTop: first * ROW, padBottom: (total - last) * ROW }; } // Accessibility survives windowing only if you declare the real dimensions. <div role="grid" aria-rowcount={total}> <div style={{ height: padTop }} aria-hidden="true" /> {rows.slice(first, last).map((r, i) => ( <div role="row" aria-rowindex={first + i + 1} key={r.id} style={{ height: ROW }}> <span role="gridcell">{r.name}</span> </div> ))} <div style={{ height: padBottom }} aria-hidden="true" /> </div>
| Failure | What the user sees | Fix |
|---|---|---|
| Overscan too small | White flashes on fast scroll | Overscan 5–10 rows, or render on scroll end |
| Unmeasured variable rows | Scrollbar jumps as content loads | Measure and cache offsets, anchor scroll |
| Ctrl+F finds nothing | Users think data is missing | Provide in-app search over the full dataset |
| Screen reader reads 24 of 10,000 | Navigation is unusable | aria-rowcount and aria-rowindex |
| Print or export blank | Report contains only visible rows | Separate non-virtualized export path |
Reversibility
Offload parsing, aggregation, and transforms.
// worker.ts — aggregate 500k rows into chart buckets, return a transferable. // Structured clone of 50MB costs ~200ms; a transferred ArrayBuffer costs ~0ms. self.onmessage = (e: MessageEvent<{ rows: Float64Array; buckets: number }>) => { const { rows, buckets } = e.data; const out = new Float64Array(buckets); const counts = new Uint32Array(buckets); for (let i = 0; i < rows.length; i += 2) { const b = Math.min(buckets - 1, (rows[i] * buckets) | 0); out[b] += rows[i + 1]; counts[b] += 1; } for (let b = 0; b < buckets; b++) if (counts[b]) out[b] /= counts[b]; self.postMessage(out, [out.buffer]); // transfer, do not copy };
| Work | Main thread | Worker | Verdict |
|---|---|---|---|
| Parse 50MB CSV | ~2.5s frozen UI | ~2.6s, UI responsive | Always worker |
| Aggregate 500k points | ~180ms jank | ~190ms off-thread | Worker |
| Sort 5k table rows | ~8ms | ~8ms + 2ms messaging | Main thread — worker adds latency |
| Format 200 currency cells | < 1ms | Overhead dominates | Main thread |
| Decode + resize images | Blocks paint | createImageBitmap in worker | Worker |
WebSockets, throttling, and canvas charts.
// Ring buffer + rAF coalescing: 600 msg/s becomes at most 60 paints/s. class Series { private buf: Float64Array; private head = 0; private len = 0; constructor(readonly cap: number) { this.buf = new Float64Array(cap); } push(v: number) { this.buf[this.head] = v; this.head = (this.head + 1) % this.cap; // no allocation, no GC pressure this.len = Math.min(this.len + 1, this.cap); } forEach(fn: (v: number, i: number) => void) { const start = (this.head - this.len + this.cap) % this.cap; for (let i = 0; i < this.len; i++) fn(this.buf[(start + i) % this.cap], i); } } const series = new Series(1_800); // 30 min at 1Hz let queued = 0; socket.onmessage = (e) => { series.push(JSON.parse(e.data).value); // never setState here queued ||= requestAnimationFrame(() => { queued = 0; draw(series); }); }; // Backgrounded tabs get throttled to ~1Hz by the browser anyway — stop the socket // instead of accumulating a 20-minute backlog that replays as a 3s freeze on return. document.addEventListener("visibilitychange", () => { document.hidden ? socket.close() : reconnectAndBackfill(series.lastTimestamp); });
| Renderer | Comfortable ceiling | Interaction | Trade-off |
|---|---|---|---|
| SVG / DOM per point | ~1,000 nodes | Native hit testing, a11y | Layout cost explodes past 2k |
| Canvas 2D | ~100,000 points | Manual hit testing | No DOM semantics; needs a table fallback |
| WebGL | 1M+ points | Manual everything | Context loss handling, shader complexity |
| Server-rendered image | Unlimited | None | Zero client cost, zero interactivity |
INP
Targets for media and data-heavy surfaces.
| Surface | Budget | Measure | Action on breach |
|---|---|---|---|
| Video startup | < 2s to first frame (p75) | QoE player events | Lower start rendition, warm the CDN |
| Rebuffer ratio | < 0.5% of watch time | QoE per PoP | Failover CDN, widen the ladder |
| Dashboard TTI | < 3s on mid-tier mobile | RUM + Lighthouse CI | Defer widgets below the fold |
| Table scroll | 60fps with 10k rows | Long Animation Frames API | Increase overscan, cut per-row work |
| Worker parse | < 500ms for 50MB CSV | Worker timing postMessage | Stream-parse in chunks |
| JS on dashboard route | < 250KB gzip | CI bundle budget | Fail the PR, dynamic-import the chart lib |
Budgets only work when a breach has a named owner and an automatic consequence. Wire bundle budgets into CI so the pull request fails, and route RUM regressions to the team that owns the route rather than to a shared dashboard nobody reads.
Cost
Media and dashboards at staff depth.
Media and dashboard interview questions.
Test your understanding — 5 questions
Playback telemetry shows users on degrading connections stall mid-video rather than dropping quality. How should the player's ABR policy be tuned?