Live Video Streaming in a Fleet App: From 8-Second Lag to Sub-Second Latency - and the 4-Player Architecture That Made It Work
The first time I demoed the live video feature, the client watched his truck's front camera feed and said: "Why is the driver already past the traffic light on the GPS map but the video still shows him stopped?"
I had a five-second HLS delay. To someone watching a live feed while simultaneously tracking the vehicle on a map, that gap was immediately obvious and completely unacceptable. I said I'd look into it. I didn't really know what "fixing latency" meant yet.
That was the beginning of six months going from "video works" to "video works well enough for someone to make a real-time safety decision." This article covers the full arc - why HLS failed, how Jessibuca solved it, and then the much harder problem I didn't anticipate: the fleet's cameras don't all speak the same protocol. I ended up building a classification engine that routes between four completely different video technologies in a single view, and the lifecycle management to clean them all up without crashing iOS.
Why HLS wasn't going to work
I spent a few days trying to tune my way out of the latency problem before accepting it wasn't possible.
HLS breaks the stream into segments - typically 2–6 seconds each - and buffers several before playback starts. That architecture is great for CDN delivery and reliability. For live surveillance it's fundamentally wrong. I tried everything: lowered segment size, reduced buffer depth, enabled low-latency mode with the full VHS configuration:
html5: { vhs: { overrideNative: true, fastPlayThreshold: 0, maxBufferLength: 30, lowLatencyMode: true }, nativeAudioTracks: false, nativeVideoTracks: false }, liveui: true, liveTracker: { trackingThreshold: 0, liveTolerance: 15 }
The best I got was about 2.5 seconds. Still too slow. If a driver hits the panic button and the operator switches to live video, 2.5 seconds means the event might already be over by the time they see anything. I needed a different transport entirely.
Finding Jessibuca and the one setting that changed everything
After going down a rabbit hole of low-latency streaming options I landed on Jessibuca - a pure JavaScript player built on WebCodecs and MediaSource Extensions for hardware-accelerated decoding. No plugins, no native code. It supports FLV over WebSocket, and FLV doesn't chunk. It streams continuously, frame by frame, over a persistent WebSocket connection. You set the buffer size yourself and playback starts as soon as there's enough data.
This is the configuration that got me under a second:
this.player = new Jessibuca({ container: this.target.nativeElement, decoder: "assets/jessibuca/decoder.js", isResize: false, loadingText: "Loading video...", videoBuffer: 0.5, isFlv: true, isH265: true, hasAudio: true, operateBtns: { fullscreen: false, screenshot: false, play: false, audio: false, record: false }, forceNoOffscreen: true, isNotMute: false, audio: true, timeout: 20000, heartTimeout: 10000, heartTimeoutReplay: true, });
videoBuffer: 0.5 is the single most important line. It tells Jessibuca to start playback with 500ms of data buffered. That's it. That's the setting that gets you under a second. The trade-off is frame drops on poor connections, but for surveillance, a slightly choppy feed from 400ms ago is more useful than a perfect feed from five seconds ago.
I disabled all built-in UI controls via operateBtns and built my own. Jessibuca's defaults don't fit Ionic's design system and I needed custom behaviour anyway - a mute toggle, fullscreen that works across iOS, Android and the web, and a keep-alive prompt I'll explain later.
Getting Jessibuca into an Angular build without breaking everything
Jessibuca isn't on npm. No TypeScript types. It's a standalone JavaScript file with a WASM decoder binary you drop into your assets folder and reference at runtime.
My first attempt was importing it through Angular's build system. The Nx build started throwing WASM compilation errors within about 20 minutes and I watched the main bundle size jump by 800KB for a feature that not every user would even open. I stopped fighting the build system and loaded it dynamically at runtime instead.
The tricky part was the 4-camera grid view - four player instances initialising simultaneously. Without coordination, all four would race to inject the script, creating four parallel loads and four Jessibuca globals on the window, which caused unpredictable WASM decoder conflicts. The fix was a static singleton promise shared across all component instances:
private static jessibucaLoadingPromise: Promise<void> | null = null; private static jessibucaLoaded: boolean = false; private async loadJessibuca(): Promise<void> { if (typeof Jessibuca !== "undefined" && JessibucaVideoPlayerComponent.jessibucaLoaded) { this.jessibucaLoaded = true; return Promise.resolve(); } if (JessibucaVideoPlayerComponent.jessibucaLoadingPromise) { return JessibucaVideoPlayerComponent.jessibucaLoadingPromise; } JessibucaVideoPlayerComponent.jessibucaLoadingPromise = new Promise( (resolve, reject) => { const script = document.createElement("script"); script.src = "assets/jessibuca/jessibuca.js"; script.async = true; script.onload = () => { JessibucaVideoPlayerComponent.jessibucaLoaded = true; this.jessibucaLoaded = true; resolve(); }; script.onerror = () => { JessibucaVideoPlayerComponent.jessibucaLoadingPromise = null; reject(new Error("Failed to load Jessibuca library")); }; document.head.appendChild(script); } ); return JessibucaVideoPlayerComponent.jessibucaLoadingPromise; }
The first instance starts loading the script. The other three wait on the same promise. On error, the static promise resets to null so a retry is possible - on very slow 3G connections the WASM decoder sometimes fails to download, and the next component that needs it should try again rather than permanently giving up.
Each component also adds a random 0–100ms delay before initialising to avoid all four hitting the script load simultaneously:
ngOnInit() { const delay = Math.random() * 100; setTimeout(() => { this.loadJessibuca() .then(() => this.initializePlayer()) .catch((error) => { this.videoError.emit({ type: "library_load_failed", message: "Failed to load video player library", error: error, }); }); }, delay); }
The consequence: the main bundle stays small, the WASM only loads when someone actually opens the video page, and users who never touch the camera feature never download 800KB of binary.
Keeping streams alive through tunnels and dead zones
The fleet operates across South Africa's N1, N2 and N3 highways, including mountain passes where cellular coverage disappears for minutes at a time. My first version without auto-reconnect generated a steady stream of support calls. "The video stopped, how do I restart it?"
heartTimeoutReplay: true in the Jessibuca config handles the basic case - if the heartbeat fails, the player reconnects automatically. But I needed more control on iOS where WebKit's WebSocket handling is less resilient than Chrome's. I added platform-aware connection monitoring with progressive backoff:
private startConnectionMonitoring() { this.lastDataTimestamp = Date.now(); this.connectionCheckInterval = setInterval(() => { if (!this.player || this.isDestroyed) { clearInterval(this.connectionCheckInterval); return; } const now = Date.now(); const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); const timeoutDuration = isIOS ? 60000 : 30000; if (this.lastDataTimestamp > 0 && now - this.lastDataTimestamp > timeoutDuration && !this.isConnected) { if (isIOS) { this.handleIOSConnectionLoss(); } else { this.reloadVideo(); } } }, 10000); }
The iOS timeout is 60 seconds versus 30 on other platforms. I found this through painful trial and error - WebKit detects dead WebSocket connections much more slowly than Chrome does, and setting 30 seconds on iOS was triggering false failures on connections that were actually still alive but stalled during a cellular handoff. The iOS path also has progressive backoff - each reconnection attempt waits 2s, 4s, 6s longer than the last, and stops after 5 failures rather than hammering the camera endpoint indefinitely. The consequence of getting this right was the support calls about video dropping going away entirely.
The keep-alive prompt
About a month into production I noticed something in usage patterns. Operators were opening camera feeds, getting distracted - a phone call, a shift handover - and leaving streams running for hours unattended. Some camera hardware only supports 2–3 concurrent viewer sessions. An abandoned stream from one operator was blocking another from viewing the same camera.
I added a configurable keep-alive prompt. After a timeout period (configurable per fleet), the first player pauses and shows an Ionic AlertController dialog. The operator either confirms they're still watching - stream restarts - or doesn't respond, and resources get released cleanly.
Only the first camera in the grid manages the keep-alive timer, set through [enableContinueWatching]="i === 0" from the parent. Without that, four cameras would fire four separate prompts simultaneously. One dialog, four cameras handled.
Then I found out the cameras don't all speak the same protocol
Getting Jessibuca working was the first half. The second half hit me when I started testing against the full fleet.
Different hardware vendors. Different firmware versions. Different streaming capabilities. One camera outputs FLV over WebSocket. Another provides HLS m3u8 playlists. A third only offers an iframe-based vendor player behind a jsession URL. A fourth uses plain HTTP-FLV with a URL that doesn't even have a file extension.
The operator doesn't care about any of this. They tap a vehicle and expect video. So I had to build a single view that figures out what the camera is offering and activates the right player automatically - without the user ever knowing there was a decision being made.
The four formats and the priority order
| Format | Player | Latency | Detection |
|---|---|---|---|
| WebSocket-FLV | Jessibuca | < 1 second | URL contains .flv, no jsession |
| HTTP-FLV (no ext) | Jessibuca | < 1 second | No known extension, no jsession, no .m3u8 |
| HLS (m3u8) | Video.js | 3–8 seconds | URL contains .m3u8 |
| H5F (jsession) | iframe | Varies | URL contains jsession |
FLV wins if it's available - Jessibuca gets it under a second. HLS is the fallback for older cameras. The iframe vendor player is the last resort - it works, but I lose control over the UI entirely. No keep-alive prompt, no custom controls, no cleanup.
The classification engine
When a vehicle is selected the app fetches all available stream URLs from the BFF. The classification is pure string matching - it sounds fragile but camera APIs return URLs in predictable formats and this has held up across every firmware version I've encountered:
private tryFlvVideos(deviceId: string) { this.videoService.getVideosFLV(deviceId) .pipe(takeUntil(this.destroy$)) .subscribe((response: any) => { if (response?.success && Array.isArray(response.data)) { const streams = response.data.filter( url => url && url.trim() !== "" ); const flvStreams = streams.filter( url => url.includes(".flv") && !url.includes("jsession") ); const otherFlvStreams = streams.filter( url => !url.includes("jsession") && !url.includes(".m3u8") && !url.includes(".flv") ); const jsessionUrls = streams.filter( url => url.includes("jsession") ); this.flvStreams = [...flvStreams, ...otherFlvStreams]; this.h5fStreams = jsessionUrls; if (this.flvStreams.length > 0) { this.currentViewMode = "flv"; this.flvCondition = true; this.openWindowFLVHere(); return; } if (this.h5fStreams.length > 0) { this.currentViewMode = "h5f"; this.url1 = this.h5fStreams[0]; setTimeout(() => { if (!this.isComponentDestroyed) { this.openWindowHere(); this.h5fCondition = true; this.cdr.detectChanges(); } }, 150); return; } } }); }
The otherFlvStreams category exists because of a firmware incident. A batch of cameras got a firmware update that silently changed their URL format - the new URLs had no file extension at all, just a bare WebSocket endpoint. Nothing in my original classifier matched them and the whole fleet fell through to the iframe fallback. Videos looked worse and the keep-alive prompt stopped working. The catch-all for unrecognised non-jsession, non-m3u8 URLs - routing them to Jessibuca - was added to fix that. Jessibuca handles multiple FLV transport variants gracefully.
The 150ms delay before activating the H5F view is a race condition guard. When switching vehicles, the previous Jessibuca players need time to tear down their WebSocket connections and WASM decoders. Activate the iframe immediately and the old cleanup races the new initialisation - on iOS this would occasionally crash the WebView.
The template - only one player exists at a time
My first attempt was to render all three player types and show or hide them with CSS flags. Wrong.
Video.js instances start loading media even when hidden with display: none. They were opening HLS connections, downloading segments, consuming bandwidth for video the user couldn't see. I found this by watching network traffic and noticing requests going out to camera endpoints that weren't supposed to be active.
The fix is *ngIf - only the active player type exists in the DOM at all:
<!-- H5F: vendor iframe --> <div class="h5f-container" *ngIf="h5fCondition && !isLoading"> <iframe [src]="urlSafe" allow="fullscreen; picture-in-picture" sandbox="allow-scripts allow-same-origin allow-forms" (load)="onIframeLoad()"> </iframe> </div> <!-- HLS: Video.js grid --> <div class="video-grid" *ngIf="ignitionOffCondtion && !isLoading"> <div class="video-cell" *ngFor="let vidUrl of testArray; trackBy: trackByFn"> <app-vjs-player [options]="vidUrl" (videoReady)="onFlvVideoReady()"> </app-vjs-player> </div> </div> <!-- FLV: Jessibuca grid --> <div class="video-grid" *ngIf="flvCondition && !isLoading"> <div class="video-cell" *ngFor="let stream of flvStreams; let i = index"> <app-jessibuca-video-player [streamUrl]="stream" [enableContinueWatching]="i === 0" (videoReady)="onFlvVideoReady()" (videoError)="onVideoError($event)"> </app-jessibuca-video-player> </div> </div>
When flvCondition is true, the HLS and H5F blocks don't exist in the DOM - no initialisation, no connections, no bandwidth. The brief remount cost when switching between types is nothing compared to the phantom bandwidth consumption the CSS approach was generating.
The BFF extracts the URLs so firmware changes don't break the app
The upstream telematics API returns a flat object with keys like flv_video_url_ch1, flv_video_url_ch2, h5f_video_url, m3u8_video_url_ch1. Those naming conventions change between camera firmware versions. Rather than making the mobile app understand all of that, the Node.js BFF extracts them by prefix and returns a clean array:
async function getAllFlvVideos(data) { const response = await axios.get( helpers.returnUrl() + '/api/video?clientId=' + data.Uid + '&deviceNr=' + data.deviceID, { headers: { "Authorization": helpers.returnAuth() } } ); const flvs = []; response.data.forEach(item => { Object.keys(item).forEach(key => { if (key.startsWith('flv_video_url_ch') || key.startsWith('h5f_video_url')) { flvs.push(item[key]); } }); }); return flvs; }
When a firmware update renames URL keys - which happened twice during the project - I updated the BFF and redeployed. The mobile app, which takes days to clear App Store review, never changed.
The 2x2 camera grid
Most trucks have four cameras. The grid uses CSS to handle the layout automatically:
.video-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 8px; padding: 8px; } .video-cell { aspect-ratio: 16/9; border-radius: 8px; overflow: hidden; background: #000; }
Four streams gives a 2x2 on tablets, single column on phones. Two streams gives side-by-side. One stream fills the width. auto-fit handles it without any JavaScript. Each cell is an independent player instance - a failure in one camera doesn't affect the others. I considered sharing a single WebSocket connection across all four cameras to reduce overhead, but one camera's network issue would have taken down all four feeds. The isolation is worth the extra connections.
The fallback chain
When the preferred format fails the component doesn't show an error - it drops to the next format:
onVideoError(event: any): void { if (this.currentViewMode === 'flv') { this.cleanupCurrentPlayers(); this.tryHLSFallback(); } }
An FLV endpoint that's overloaded can often still be accessed via the HLS endpoint, which uses a different server-side path. I noticed this while debugging a camera that kept dropping its WebSocket connection - the HLS URL for the same camera was responding perfectly. The fallback turned that observation into a feature. The operator sees a brief pause and then slightly higher latency video, not a black screen.
Cleanup - this is where everything gets hard
Managing the lifecycle of four different player technologies is the hardest part of this entire system. Each one has a different disposal sequence. Get any of them wrong and you get memory leaks, phantom WebSocket connections, or - on iOS - a WebView that crashes after navigating in and out of the video page eight or nine times.
The page cleanup runs on ionViewDidLeave and has to handle every player type defensively regardless of which one was actually active:
ionViewDidLeave() { if (this.isComponentDestroyed) return; this.isNavigating = true; this.isComponentDestroyed = true; this.destroy$.next(); this.destroy$.complete(); this.h5fCondition = false; this.ignitionOffCondtion = false; this.flvCondition = false; if (this.interval) { clearInterval(this.interval); this.interval = null; } this.flvStreams = []; this.h5fStreams = []; this.testArray = []; if (this.platform.is("ios")) { this.performIOSCleanup(); } else { this.performStandardCleanup(); } }
Standard cleanup calls disposal on all player types, not just the active one - a player might have been partially initialised before a failure switched to the fallback format:
private performStandardCleanup() { try { this.player.destroyAllPlayers(); // Video.js } catch (error) {} this.forceJessibucaCleanup(); this.url1 = ""; this.urlSafe = this.sanitizer.bypassSecurityTrustResourceUrl(""); try { this.cdr.detectChanges(); } catch (error) {} this.alertController.getTop() .then(alert => { if (alert) alert.dismiss(); }) .catch(() => {}); }
The try/catch around every disposal call isn't defensive programming for its own sake. A player that was mid-initialisation when navigation happened throws during cleanup. A player whose WebSocket was already lost throws during pause(). Without the catches, the first failure stops the rest of the cleanup from running, leaking everything that comes after it. I found each one of these by watching the browser's memory tab climb and working backwards.
iOS - three layers of cleanup
Standard cleanup isn't enough on iOS. WebKit doesn't release media resources when components are removed from the DOM. I have to manually hunt down video elements and force-clear them:
private performIOSCleanup() { this.performStandardCleanup(); this.cleanupTimeout = setTimeout(() => { this.forceJessibucaCleanup(); try { const videoElements = document.querySelectorAll("video"); videoElements.forEach((video) => { try { video.pause(); video.src = ""; video.load(); } catch (error) {} }); } catch (error) {} if ((window as any).gc) { try { (window as any).gc(); } catch (error) {} } }, 500); }
The 500ms delay is because iOS needs time to finish removing Angular components from the DOM before querySelectorAll can reliably find the orphaned elements. Query immediately and you hit a race condition. The video.src = ""; video.load() sequence is a documented WebKit workaround - setting src to empty doesn't release the underlying MediaSource buffer. You have to call load() afterwards to force WebKit to actually let go of it.
Jessibuca cleanup has three layers specifically because it creates more resources than any other player - WebSocket connections, WASM decoder instances, canvas elements, and global window references:
private forceJessibucaCleanup() { // Layer 1: Component lifecycle this.stopAndDestroyAllJessibucaPlayers(); // Layer 2: DOM fallback setTimeout(() => { try { const elements = document.querySelectorAll("app-jessibuca-video-player"); elements.forEach(el => { try { el.remove(); } catch (error) {} }); // Layer 3: Global reference cleanup if ((window as any).jessibucaInstances) { try { (window as any).jessibucaInstances.forEach((instance: any) => { if (instance && instance.destroy) instance.destroy(); }); (window as any).jessibucaInstances = []; } catch (error) {} } } catch (error) {} }, 1000); }
Layer 1 uses Angular's component lifecycle. Layer 2 falls back to raw DOM queries in case Angular's change detection missed something. Layer 3 clears any global references Jessibuca's internal code registered on the window. This three-layer approach came from months of debugging - the WebView was crashing after 8–10 video navigations on iOS and each layer of cleanup I added pushed that number higher until the crashes stopped entirely.
After six months - what the numbers looked like
| Player | Transport | Typical latency |
|---|---|---|
| Video.js | HLS | 3–8 seconds |
| Legacy FLV.js | FLV | 1–3 seconds |
| Jessibuca | FLV over WebSocket | 300ms–800ms |
The client watched the feed after I deployed the Jessibuca integration. Same question as before: "Why is the driver past the traffic light but the video still shows him stopped?" This time he watched for a few seconds. The truck moved on the map. The video moved at the same time. He didn't say anything, which is the best response you can get.
Getting there required solving three separate problems - the transport architecture, the multi-format classification, and the platform-specific lifecycle management. None of them were obvious upfront. Each one only became visible after the previous one was solved.
This article is part of a series on building a fleet telematics platform.