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.
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.
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.
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).
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.
Our application will consist of two parts:
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:
app.viziot.com:54321. The default example is configured for it.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).
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
\. "$HOME/.nvm/nvm.sh"
nvm install 24
node -v
npm -v
mkdir viziot_udp_jitter
cd viziot_udp_jitter/
npm init -y
npm i viziot-mqtt-client-nodejs
nano client.js
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_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' });
});
To keep the script running 24/7, we'll use the PM2 process manager.
npm install pm2 -gpm2 start client.js --name "jitter-monitor"pm2 startup (and execute the command it suggests)pm2 saveIf 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.
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.