Low Latency (50ms) Video Streaming with NODE.JS and html5


I'm building a FPV robot, I want to control it with a with a webbrowser over a local wi-fi connection.

I'm using a raspberry pi 3B+ with Raspbian Stretch. I built my own motor control and power regulator hat.

After lots of research testing, I decided to use node.JS as http server and socket.io to provide a low latency bidirectional communication with my robot. This stack achieve about 7ms of latency.

Picture of the robot


I need to stream low latency video from an USB camera attached to the RPI to the browser. My target is to achieve at least 640x480 resolution at 10FPS with 50ms of latency or better. I'm happy sacrificing visual fedelity to get a quicker response from my robot.

If possible I would like to stream in UDP to improve the reliability of the stream.

If possible I would like to stream a video that modern webbrowsers can natively decode. I'd like to use a H264 codec and the HTML5 video tag. I can fall back to use a javascript player if there is no other option.

<h2>WHAT I TRIED:</h2>

I did an extensive research and tried many tools.

Amongst other, I tried VLC, mjpg streamer, gstreamer and raspivid. A few times I got to a stream the webbrowser could view, but at best I got a latency of 700ms at 320x240. Very very far from my target.

Currently I'm looking into WebRTC solutions.


I'd like suggestions for NODE.JS packages or other solutions to provide a UDP H264 video stream that can be decoded by an HTML5 video tag with a target latency of 50ms.



Thanks for your answers! I'll keep updating this question and I'll post the solution once it works.


I tried a different approach by pushing individual 200KB 640x480 jpg frame through websocket and I got a latency of about 190ms. I can probably do a lot better by reusing objects but I'm putting this attempt in hold for now.


While researching WebRTC I found a stack that looked easy enough. Server side it uses V4L2 as driver, FFMPEG to transcode into an MPEG1 http stream with TS encapsulation locally, node js to flip the stream into a websocket. Client side there is a javascript that decode the MPEG1 TS stream and paint a canvas object into the HTML page.

It achieves 640x480@20FPS with 240mS of latency. Good enough for an MVP, but I'll keep working to get it down. Code in the answer.


I’d like suggestions for NODE.JS packages or other solutions to provide a UDP H264 video stream that can be decoded by an HTML5 video tag with a target latency of 50ms.


That’s Almost certainly not possible in that configuration.

If you drop the video tag requirement, and use just straight WebRTC in the browser, you may be able to get down to about 150ms.


I adapted code from here and integrated it with an http server and socket.io controls: https://github.com/phoboslab/jsmpeg


V4L2 -> FFMPEG (MPEG1 TS) -> NODE HTTP Server -> NODE Websocket broadcast


Websocket -> Javascript (Decode MPEG1 TS and paint to html canvas) -> Html Canvas

This stack achieve 640x480@20FPS with 240ms of latency. Still far from my target but good enough as MVP. The controls in both directions have a latency of 7ms, which is excellent.

This stack is held back by the transcoding and decoding stage, and the RPI gets really hot. The transport of raw data through websocket looks good, I'm going to profile the latency of each steps in the future.


<pre class="snippet-code-html lang-html prettyprint-override">pi@MazeRunner:~ $ node node.js & pi@MazeRunner:~ $ ffmpeg -f v4l2 -framerate 20 -video_size 640x480 -i /dev/video0 -f mpegts -codec:v mpeg1video -s 640x480 -b:v 600k -bf 0 http://localhost:8080/mystream

Server side NODE.JS

<pre class="snippet-code-js lang-js prettyprint-override">//operating system library. Used to get local IP address var os = require("os"); //file system library. Used to load file stored inside back end server (https://nodejs.org/api/fs.html) var fs = require("fs"); //http system library. Handles basic html requests var http = require("http").createServer(http_handler); //url library. Used to process html url requests var url = require("url"); //Websocket var io = require("socket.io")(http); //Websocket used to stream video var websocket = require("ws"); //----------------------------------------------------------------------------------- // CONFIGURATION //----------------------------------------------------------------------------------- //Port the server will listen to var server_port = 8080; var websocket_stream_port = 8082; //Path of the http and css files for the http server var file_index_name = "index.html"; var file_css_name = "style.css"; var file_jsplayer_name = "jsmpeg.min.js"; //Http and css files loaded into memory for fast access var file_index; var file_css; var file_jsplayer; //Name of the local video stream var stream_name = "mystream"; //----------------------------------------------------------------------------------- // DETECT SERVER OWN IP //----------------------------------------------------------------------------------- //If just one interface, store the server IP Here var server_ip; //Get local IP address of the server //https://stackoverflow.com/questions/3653065/get-local-ip-address-in-node-js var ifaces = os.networkInterfaces(); Object.keys(ifaces).forEach ( function (ifname) { var alias = 0; ifaces[ifname].forEach ( function (iface) { if ('IPv4' !== iface.family || iface.internal !== false) { // skip over internal (i.e. and non-ipv4 addresses return; } if (alias >= 1) { // this single interface has multiple ipv4 addresses console.log('INFO: Server interface ' +alias +' - ' + ifname + ':' + alias, iface.address); } else { server_ip = iface.address; // this interface has only one ipv4 adress console.log('INFO: Server interface - ' +ifname, iface.address); } ++alias; } ); } ); //----------------------------------------------------------------------------------- // HTTP SERVER //----------------------------------------------------------------------------------- // Fetch and serves local files to client //Create http server and listen to the given port http.listen ( server_port, function( ) { console.log('INFO: ' +server_ip +' listening to html requests on port ' +server_port); //Pre-load http, css and js files into memory to improve http request latency file_index = load_file( file_index_name ); file_css = load_file( file_css_name ); file_jsplayer = load_file( file_jsplayer_name ); } ); //----------------------------------------------------------------------------------- // HTTP REQUESTS HANDLER //----------------------------------------------------------------------------------- // Answer to client http requests. Serve http, css and js files function http_handler(req, res) { //If client asks for root if (req.url == '/') { //Request main page res.writeHead( 200, {"Content-Type": detect_content(file_index_name),"Content-Length":file_index.length} ); res.write(file_index); res.end(); console.log("INFO: Serving file: " +req.url); } //If client asks for css file else if (req.url == ("/" +file_css_name)) { //Request main page res.writeHead( 200, {"Content-Type": detect_content(file_css_name),"Content-Length" :file_css.length} ); res.write(file_css); res.end(); console.log("INFO: Serving file: " +req.url); } //If client asks for css file else if (req.url == ("/" +file_jsplayer_name)) { //Request main page res.writeHead( 200, {"Content-Type": detect_content(file_jsplayer_name),"Content-Length" :file_jsplayer.length} ); res.write(file_jsplayer); res.end(); console.log("INFO: Serving file: " +req.url); } //Listening to the port the stream from ffmpeg will flow into else if (req.url = "/mystream") { res.connection.setTimeout(0); console.log( "Stream Connected: " +req.socket.remoteAddress + ":" +req.socket.remotePort ); req.on ( "data", function(data) { streaming_websocket.broadcast(data); /* if (req.socket.recording) { req.socket.recording.write(data); } */ //console.log("broadcast: ", data.length); } ); req.on ( "end", function() { console.log("local stream has ended"); if (req.socket.recording) { req.socket.recording.close(); } } ); } //If client asks for an unhandled path else { res.end(); console.log("ERR: Invalid file request" +req.url); } } //----------------------------------------------------------------------------------- // WEBSOCKET SERVER: CONTROL/FEEDBACK REQUESTS //----------------------------------------------------------------------------------- // Handle websocket connection to the client io.on ( "connection", function (socket) { console.log("connecting..."); socket.emit("welcome", { payload: "Server says hello" }); //Periodically send the current server time to the client in string form setInterval ( function() { socket.emit("server_time", { server_time: get_server_time() }); }, //Send every 333ms 333 ); socket.on ( "myclick", function (data) { timestamp_ms = get_timestamp_ms(); socket.emit("profile_ping", { timestamp: timestamp_ms }); console.log("button event: " +" client says: " +data.payload); } ); //"ArrowLeft" socket.on ( "keyboard", function (data) { timestamp_ms = get_timestamp_ms(); socket.emit("profile_ping", { timestamp: timestamp_ms }); console.log("keyboard event: " +" client says: " +data.payload); } ); //profile packets from the client are answer that allows to compute roundway trip time socket.on ( "profile_pong", function (data) { timestamp_ms_pong = get_timestamp_ms(); timestamp_ms_ping = data.timestamp; console.log("Pong received. Round trip time[ms]: " +(timestamp_ms_pong -timestamp_ms_ping)); } ); } ); //----------------------------------------------------------------------------------- // WEBSOCKET SERVER: STREAMING VIDEO //----------------------------------------------------------------------------------- // Websocket Server var streaming_websocket = new websocket.Server({port: websocket_stream_port, perMessageDeflate: false}); streaming_websocket.connectionCount = 0; streaming_websocket.on ( "connection", function(socket, upgradeReq) { streaming_websocket.connectionCount++; console.log ( 'New websocket Connection: ', (upgradeReq || socket.upgradeReq).socket.remoteAddress, (upgradeReq || socket.upgradeReq).headers['user-agent'], '('+streaming_websocket.connectionCount+" total)" ); socket.on ( 'close', function(code, message) { streaming_websocket.connectionCount--; console.log('Disconnected websocket ('+streaming_websocket.connectionCount+' total)'); } ); } ); streaming_websocket.broadcast = function(data) { streaming_websocket.clients.forEach ( function each(client) { if (client.readyState === websocket.OPEN) { client.send(data); } } ); }; //----------------------------------------------------------------------------------- // FUNCTIONS //----------------------------------------------------------------------------------- //----------------------------------------------------------------------------------- // SERVER DATE&TIME //----------------------------------------------------------------------------------- // Get server time in string form function get_server_time() { my_date = new Date(); return my_date.toUTCString(); } //----------------------------------------------------------------------------------- // TIMESTAMP //----------------------------------------------------------------------------------- // Profile performance in ms function get_timestamp_ms() { my_date = new Date(); return 1000.0* my_date.getSeconds() +my_date.getMilliseconds() } //----------------------------------------------------------------------------------- // FILE LOADER //----------------------------------------------------------------------------------- // Load files into memory for improved latency function load_file( file_name ) { var file_tmp; var file_path = __dirname +"/" +file_name; //HTML index file try { file_tmp = fs.readFileSync( file_path ); } catch (err) { console.log("ERR: " +err.code +" failed to load: " +file_path); throw err; } console.log("INFO: " +file_path +" has been loaded into memory"); return file_tmp; } //----------------------------------------------------------------------------------- // CONTENT TYPE DETECTOR //----------------------------------------------------------------------------------- // Return the right content type to give correct information to the client browser function detect_content( file_name ) { if (file_name.includes(".html")) { return "text/html"; } else if (file_name.includes(".css")) { return "text/css"; } else if (file_name.includes(".js")) { return "application/javascript"; } else { throw "invalid extension"; } }

Client Side html

<pre class="snippet-code-html lang-html prettyprint-override"><!DOCTYPE html> <meta charset="utf-8"/> <html> <head> <title>Maze Runner</title> <link rel="stylesheet" href="style.css"> <script type="text/javascript" src="/socket.io/socket.io.js"></script> <script type="text/javascript"> var host_ip = document.location.hostname; console.log("connecting to host: ", host_ip); //Get references to the html controls textbox_input1 = window.document.getElementById("my_text_box") //Connect to the server via websocket var mysocket = io("http://" +host_ip +":8080"); //Long lived frame object var last_frame; //----------------------------------------- // CONNESSION ACKNOWLEDGE //----------------------------------------- // Link is initiated by the client // Server sends a welcome message when link is estabilished // Server could send an auth token to keep track of individual clients and login data mysocket.on ( "welcome", (message) => { console.log("Server websocket connession acknoweldged... " +message.payload); } ) //----------------------------------------- // SERVER->CLIENT CONTROLS //----------------------------------------- // Server can send an async message to dinamically update the page without reloading // This is an example message with the server local date and time in string form mysocket.on ( "server_time", (message) => { fill_label( message.server_time ); console.log("Server sent his local time... " +message.server_time); } ) function fill_label( payload ) { textbox_input1.value=payload; } //----------------------------------------- // CLIENT->SERVER CONTROLS //----------------------------------------- // Controls inside the webpage can emit async events to the server // In this example I have a push button and I catch keyboard strokes //Handler for a pushbutton function socket_button_handler() { mysocket.emit("myclick", { payload: "button was clicked" }); console.log("Button was clicked..."); } //Listen for keystrokes window.document.addEventListener ( "keypress", function onEvent(event) { //Inform the server that a key has been pressed mysocket.emit("keyboard", { payload: event.key }); console.log("Key press..."); } ); //----------------------------------------- // PING-PONG //----------------------------------------- // Server sends ping messages with a timestamp // Client answers with pongs to allow server to profile latency of the channel //profile messages means the server wants to compute roundway trip mysocket.on ( "profile_ping", (message) => { //Answer back with the received timestamp so that server can compute roundway trip mysocket.emit("profile_pong", { timestamp: message.timestamp }); console.log( "server wants a pong. server absolute timestamp[ms]: " +message.timestamp ); } ); </script> </head> <body> Html+Css Server +low latency Websocket server

<!-- button control with socket emitter as handler -->

This button will emit a websocket event. The server will be informed in real time of the event.

<button id="my_button" type="button" onclick="socket_button_handler()">Websocket Button!</button> <!-- input text control -->

This input can be filled through websockets directly by the server in real time

<input id="my_text_box" type="text" value="" size="40"> <!-- canvas object, it's painted by the javascript video decoder -->

This canvas is painted by the javascript player and shows the live stream.'

<canvas id="video-canvas" width=640 height=480></canvas> <!-- Javascript video decoder, take in a data stream from a websocket and paint on a canvas --> <script type="text/javascript" src="jsmpeg.min.js"></script> <script type="text/javascript"> var mycanvas = document.getElementById("video-canvas"); var url = "ws://" + host_ip +":8082/"; var player = new JSMpeg.Player(url, {canvas: mycanvas}); </script> </body> </html>

Javascript Player

You can get the javascript player I used from here: https://github.com/phoboslab/jsmpeg/blob/master/jsmpeg.min.js



  • Rotating a video during encoding with ffmpeg and libav API results in half of video corrupted
  • How to remove TabPane background shadow
  • how to stop only the hover pointer event in css
  • Alternatives to SPARQL query with lots of UNIONs
  • Google Chart loads extremely small when included in jQuery-based tabs
  • In-place migration from TFS 2010 to TFS 2015
  • How to rotate an UIImageView using TouchesMoved
  • How to overlap textview on image view in XML?
  • iPhone - Polar H7_heart_rate_sensor connectivity using bluetooth
  • Blank texture in webGL
  • ActiveX pop-up dialogue window hides IE from tasklist
  • Google Analytics and Colorbox - Cross domain tracking
  • SQLite Exception in android
  • Python - Download files from SharePoint site
  • Maven Resources Plugin: Build won't fail if directory doesn't exists
  • Visual Studio Web Deployment - update publish profile to “Precompile during publishing”
  • AngularJs trigger a file download by calling a REST endpoint
  • What is the need to do sharding of TFRecords files?
  • How to make binary classication in Spark ML without StringIndexer
  • How to indent if…any…do…end…end [closed]
  • Program defensively against ODAC instantclient
  • Creating custom UTI for use with airdrop, iOS
  • c++ plugin : Is it ok to pass polymorphic objects?
  • Parsing a complicated array with GetJSON Jquery
  • CSV processing in Hadoop
  • Angular 6 illegal operation on a directory, open '/Users//.npm-global/lib/node_modules/
  • wxPython UltimateListCtrl check (tick) listitem programatically
  • Saving CLLocation error: Mutating method sent to immutable object
  • async GET request with body from browser
  • Php artisan optimize is failing on production server
  • Typeahead.js does give me suggestions but doesn't select them