Monitoring Jitter and Packet Loss in Node.js for Stable Connectivity

Does your video call constantly "stutter"? Does your character freeze in an online game and then "teleport" halfway across the map? Most often we blame high "ping" for this, but in reality, two other, more important parameters are responsible for quality real-time communication: jitter and packet loss.

In this article, we'll understand what these are, why they're more important than ping for video, games, and VoIP, and create our own monitoring system for these parameters using Node.js and VizIoT.

What is Jitter and Why is it Important?

All data on the internet is transmitted in small portions — packets. Ping (RTT) is the time it takes for one packet to reach the server and return. But what if packets arrive with varying delays?

Jitter is the irregularity in packet delay. Simply put, it's the "trembling" of ping.

  • Ideal (low jitter): Packets arrive at equal intervals (for example, every 20 ms, 20 ms, 20 ms). Communication is smooth.
  • Bad (high jitter): Packets arrive chaotically (for example, 10 ms, then 50 ms, then 5 ms). Devices have to wait for "late" packets, which causes stuttering, "lag," and picture breakup.

Analogy: If ping is the average travel time for a bus on a route, then jitter is how much it deviates from the schedule. A bus that always arrives 5 minutes late is better than one that arrives sometimes 10 minutes early and sometimes 20 minutes late.

Why is Packet Loss Worse Than High Ping?

Packet Loss is the percentage of packets that never reached the recipient. They simply get lost along the way due to network congestion, poor equipment, or interference.

For real-time applications like video calls or games, a lost packet is a catastrophe. The system can't wait for it forever and is forced to either ignore that data fragment (leading to audio "dropout" or picture "freezing"), or try to guess what was in it.

A slow response (high ping) is almost always better than no response (packet loss).

What Result Will We Get?

We'll create a system that will constantly send test data packets and analyze how they return. The results will be displayed on an interactive widget in VizIoT, which will show you the complete picture of your connection stability.

How Does It Work?

Our application will consist of two parts:

  1. Client: Runs on your computer (for example, Raspberry Pi or home server). It sends UDP packets to the server at high frequency.
  2. Server: Its task is to receive packets from the client as quickly as possible and send them back ("echo").

The client, receiving packets back, measures their return time, compares delays between packets (calculating jitter), and counts how many packets didn't return (packet loss).

As a server, you can use:

  • Our public server (recommended for simplicity): app.viziot.com:54321. The default example is configured for it.
  • Your own server: You can run the server part on your VPS or second device to measure connection quality between two specific points.

Step 1: Installing Node.js

Our script is written in Node.js. We'll work in Linux (for example, Ubuntu/Debian), but you can adapt this for any OS.

It's recommended to install Node.js via NVM (version manager).

  1. Install NVM:
    curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
  2. Activate NVM (without re-login):
    \. "$HOME/.nvm/nvm.sh"
  3. Install the latest version of Node.js:
    nvm install 24
  4. Check installation:
    node -v
    npm -v

Step 2: Creating the Client Application

Project Setup

mkdir viziot_udp_jitter
cd viziot_udp_jitter/
npm init -y
npm i viziot-mqtt-client-nodejs
nano client.js

Client Code (client.js)

Insert the following code. Don't forget to specify your VizIoT device key and password. If you don't yet know how VizIoT works, we recommend reading this article Introduction to VizIoT.

// client.js
const { fork } = require('child_process');
const VizIoTMQTT = require('viziot-mqtt-client-nodejs');

// ---------------------------------------
// Settings
// ---------------------------------------
// 1. Enter your VizIoT device key and password
const keyDevice = '____________';
const passDevice = '_________________';

// Server data (VizIoT public server)
const HOST = 'app.viziot.com';
const PORT = 54321;

// Test settings
const TEST_INTERVAL = 30000; // Test launch interval (30 seconds)
const COUNT_PACKETS = 500;   // Number of packets in one test
const PACKET_INTERVAL = 10;  // Delay between sending packets (10 ms)

// ---------------------------------------
// VizIoT MQTT
// ---------------------------------------
let isConnected = false;
const viziotMQTTClient = new VizIoTMQTT(keyDevice, passDevice);
viziotMQTTClient.connect(() => {
    isConnected = true;
    console.log("Successfully connected to VizIoT MQTT.");
});

// ---------------------------------------
// Working Logic
// ---------------------------------------

if (COUNT_PACKETS * PACKET_INTERVAL > TEST_INTERVAL * 0.8) {
    console.error('Configuration error: test time exceeds interval between tests!');
    process.exit(1);
}

function runTestCycle() {
    console.log(`\n▶ Starting new test (packets: ${COUNT_PACKETS})`);
    worker.send({ cmd: 'startTest' });

    let seq = 0;
    const sendInterval = setInterval(() => {
        if (seq >= COUNT_PACKETS) {
            clearInterval(sendInterval);
            setTimeout(() => {
                worker.send({ cmd: 'stopTest' });
            }, 2000); // Give 2 seconds for last packets to return
            return;
        }
        worker.send({
            cmd: 'sendPacket',
            idBuffer: Buffer.from(keyDevice, 'utf8'),
            seq: seq++,
        });
    }, PACKET_INTERVAL);
}

function showResults(stats) {
    const avgPing = stats.rtts.length ? (stats.rtts.reduce((a, b) => a + b, 0) / stats.rtts.length).toFixed(3) : 0;
    const minPing = stats.rtts.length ? Math.min(...stats.rtts).toFixed(3) : 0;
    const maxPing = stats.rtts.length ? Math.max(...stats.rtts).toFixed(3) : 0;
    const avgJitter = stats.jitters.length ? (stats.jitters.reduce((a, b) => a + b, 0) / stats.jitters.length).toFixed(3) : 0;
    const lossPercent = stats.sent > 0 ? (((stats.sent - stats.received) / stats.sent) * 100).toFixed(2) : 0;

    console.log(`
📊 REPORT
   Sent:       ${stats.sent}
   Received:   ${stats.received}
   Loss:       ${lossPercent}%
   Ping (avg): ${avgPing} ms
   Ping (min): ${minPing} ms
   Ping (max): ${maxPing} ms
   Jitter (avg):${avgJitter} ms
    `);

    let packet = {
        date: Math.floor(Date.now() / 1000),
        sent: stats.sent,
        recv: stats.received,
        loss_perc: lossPercent,
        ping_avg: avgPing,
        ping_min: minPing,
        ping_max: maxPing,
        jitter_avg: avgJitter,
    };

    // Send to VizIoT
    if (isConnected) {
        viziotMQTTClient.sendDataToVizIoT(packet, (err) => {
            if (err) console.log('Error sending to VizIoT:', err);
        });
    }
}

// UDP work moved to a separate thread for measurement accuracy
const worker = fork('./udp_worker.js');

worker.on('message', (msg) => {
    switch (msg.cmd) {
        case 'ready':
            worker.send({ cmd: 'config', host: HOST, port: PORT });
            break;
        case 'configured':
            runTestCycle(); // First test immediately on startup
            setInterval(runTestCycle, TEST_INTERVAL); // Start test cycle
            break;
        case 'report':
            showResults(msg.stats);
            break;
    }
});

UDP Helper File (udp_worker.js)

To ensure packet sending and receiving don't affect timer accuracy, we'll move all network operations to a separate process.

nano udp_worker.js

Insert this code:

// udp_worker.js
const dgram = require('dgram');
const socket = dgram.createSocket('udp4');

let SERVER_HOST = null;
let SERVER_PORT = null;
let isTesting = false;

let stats = {};

function resetStats() {
    stats = {
        sent: 0,
        received: 0,
        rtts: [],
        jitters: [],
        lastRtt: null,
    };
}

socket.on('message', (msg) => {
    if (!isTesting || msg.length < 28) return;

    const recvTime = process.hrtime.bigint();
    try {
        const msgSendTime = msg.readBigUInt64BE(20);
        const rtt = Number(recvTime - msgSendTime) / 1e6; // in milliseconds

        stats.rtts.push(rtt);
        stats.received++;

        if (stats.lastRtt !== null) {
            stats.jitters.push(Math.abs(rtt - stats.lastRtt));
        }
        stats.lastRtt = rtt;
    } catch (e) {
        console.error('Packet parsing error', e);
    }
});

process.on('message', (msg) => {
    switch (msg.cmd) {
        case 'config':
            SERVER_HOST = msg.host;
            SERVER_PORT = msg.port;
            process.send({ cmd: 'configured' });
            break;
        case 'startTest':
            resetStats();
            isTesting = true;
            break;
        case 'sendPacket': {
            const { idBuffer, seq } = msg;
            const buf = Buffer.allocUnsafe(28);
            idBuffer.copy(buf, 0);
            buf.writeUInt32BE(seq, 16);
            buf.writeBigUInt64BE(process.hrtime.bigint(), 20);
            socket.send(buf, SERVER_PORT, SERVER_HOST, (err) => {
                if (!err) stats.sent++;
            });
            break;
        }
        case 'stopTest':
            isTesting = false;
            process.send({ cmd: 'report', stats });
            break;
    }
});

socket.bind(() => {
    process.send({ cmd: 'ready' });
});

Step 3: Auto-start via PM2

To keep the script running 24/7, we'll use the PM2 process manager.

  1. Install PM2 globally: npm install pm2 -g
  2. Start the script: pm2 start client.js --name "jitter-monitor"
  3. Configure PM2 auto-start on system boot: pm2 startup (and execute the command it suggests)
  4. Save the process list: pm2 save

(Optional) Step 4: Setting Up Your Own Server

If you want to measure connection quality between two of your devices (for example, to a VPS), run this simple echo server on the second device.

# On the server
sudo ufw allow 54321/udp
mkdir udp_server && cd udp_server
npm init -y
nano server.js

Code for server.js:

const dgram = require('dgram');
const server = dgram.createSocket('udp4');
const PORT = 54321;

server.on('message', (msg, rinfo) => {
    // Simply send the packet back
    server.send(msg, rinfo.port, rinfo.address);
});

server.bind(PORT, () => {
    console.log(`UDP Echo server started on port ${PORT}`);
});

Run it also via PM2: pm2 start server.js --name "udp-echo-server". Don't forget to change HOST and PORT in client.js.


Step 5: Visualization and Alerts in VizIoT

After the first script run, data will start flowing to your VizIoT device. All that's left is to create a beautiful widget.

Create a "Chart" type widget and add parameters of line type: jitter_avg, ping_min, ping_max, ping_avg. For the loss_perc parameter, the bar type works perfectly.

Most importantly — get notifications about problems. Create a rule in the "Notifications" section: if loss_perc is greater than (>) 5, send a notification to Telegram. This way you'll know about connection problems before users start complaining.

Now you have not just a "ping meter," but a professional tool for analyzing the quality and stability of your network connection.