networkhealthchecker_web_logo

NetworkHealthChecker

v1.0.3

Table of contents

Overview

The NetworkHealthChecker C++ library provides a way to monitor the quality of a UDP network link between two endpoints and to detect when it gets better, worse, or stays stable. It is intended for applications that stream real-time data over UDP (live video, audio, telemetry) and need a simple feedback signal for their adaptive logic - bitrate control, buffering, FEC strength, and so on. The same class works in two roles, selectable at initialization time:

  • Server - actively sends probe packets to the remote side (one probe every 100 - 150 ms), processes the replies, and computes the network health metrics.
  • Client - passively waits for probe packets and echoes them back without modification. The client does not compute anything; it only serves as a reflector for the server’s measurements. Internally the library measures packet loss (via a 256-slot ring buffer indexed by sequence number) and round-trip jitter (via the RFC 3550 single-clock form). Both metrics are smoothed with EWMA filters and combined into a single float in [-1, +1], where positive means “improving relative to the previous tick”, negative means “degrading”, and zero means “stable” (or “still in warm-up”). See Calculations for the math. Everything runs in its own thread spawned at init..., so the calling application is free to poll getNetworkStatus() from any other thread without locking. All clock measurements happen on the server side, so the two endpoints do not need to have synchronized clocks. The library depends only on the UdpSocket library (see Build and connect to your project).

NetworkHealthChecker is general-purpose software: it is not tied to any sector or application, and the developer of a program decides what the program does with it.

Versions

Table 1 - Library versions.

Version Release date What’s new
1.0.0 06.05.2026 First version.
1.0.1 17.07.2026 - UdpSocket submodule updated.
1.0.2 17.10.2026 - UdpSocket sub-repository excluded: the UdpSocket library must be next to the library (stand alone build) or added by the parent project.
- Probe packets contain a random session ID instead of uninitialized memory. The server accepts only replies which are equal to a sent probe.
- The server and the client ignore datagrams which are longer or shorter than a probe. Foreign datagrams do not change the number of network health updates.
- The initAsServer(…) method checks the client IP before the port scan. Port 0 is rejected.
- The initAsClient(…), initAsServer(…) and stop() methods can be called from several threads. Errors of the thread creation are returned as FALSE.
- Strict compiler warnings, compiler and linker hardening options, standard C++ only.
- The test application checks the user input.
- Documentation updated.
1.0.3 25.10.2026 - The library links the thread library (CMake target Threads::Threads): programs built on a C library older than glibc 2.34 no longer need -pthread.

Library files

The library is supplied as source code only. The user is provided with a set of files in the form of a CMake project (repository). The repository structure is shown below:

CMakeLists.txt ------------------------ Main CMake file of the library.
src ----------------------------------- Folder with library source code.
    CMakeLists.txt -------------------- CMake file of the library.
    NetworkHealthChecker.cpp ---------- C++ implementation file.
    NetworkHealthChecker.h ------------ Main library header file.
    NetworkHealthCheckerVersion.h ----- Header file with the library version.
    NetworkHealthCheckerVersion.h.in -- File for CMake to generate version header.
test ---------------------------------- Folder for the test application.
    CMakeLists.txt -------------------- CMake file for the test application.
    main.cpp -------------------------- Source C++ file of the test application.

How it works

Roles and data

The server sends packets of 41 bytes to the client. Byte 0 contains the packet number [0, 255], bytes 1 - 8 contain a random session ID chosen by initAsServer(...) (least significant byte first), the other bytes are zero. The server is configured with the client’s IP address and the UDP port that the client listens on. The client receives a packet from the server and sends it back without any modification; datagrams of other sizes are ignored. The client does not need to be configured with the server’s address - it simply replies to whichever endpoint the incoming packet came from.

The server accepts only replies which are equal to a sent packet: exactly 41 bytes and the session ID of the server. Other datagrams (shorter, longer, damaged or sent by other hosts) are ignored, so a host which does not see the probe packets can not make valid replies. The session ID is not a protection against a host which can read the traffic between the server and the client.

Calculations

The server thread runs a loop. A probe packet is sent when more than 100 ms have passed since the last successfully sent probe. In every iteration (a tick) the thread waits up to 50 ms for one reply, updates the round-trip statistics if a reply arrived, and re-evaluates the network health. So the network health is re-evaluated after every reply and after 50 ms without a reply, and probes are sent every 100 - 150 ms. A datagram which is not a reply does not start a tick, unless 50 ms have passed since the last tick. All calculations live in the server thread, no locking is required for internal state, and only the final health value is exposed to the outside world via an std::atomic<float>.

Jitter

Jitter is computed as the smoothed variation of round-trip time between consecutive probes. The classical RFC 3550 inter-arrival jitter formula

D(i, i-1) = (R_i - R_{i-1}) - (S_i - S_{i-1})

is used in the single-clock form: both R (reply arrival time at the server) and S (probe send time at the server) live in the server’s clock, so the formula collapses to consecutive-RTT differences:

D = (R_i - R_{i-1}) - (S_i - S_{i-1})
  = (R_i - S_i) - (R_{i-1} - S_{i-1})        // regroup
  = RTT_i - RTT_{i-1}                        // since RTT = R - S

The two forms are mathematically identical, so the implementation tracks only the previous RTT sample (prevRttSampleUs) instead of separate R and S history.

The absolute value of D feeds an EWMA smoothing filter (α = 1 / 16) implemented with an integer accumulator scaled by 16 (jitterAccum = 16 * J), using fixed-point arithmetic:

jitterAccum += |D| - ((jitterAccum + 8) >> 4)
J(i)        = jitterAccum >> 4   // in microseconds

The >> 4 operation is equivalent to dividing by 16. The (jitterAccum + 8) >> 4 rounding term ensures proper rounding of the fixed-point division.

Because both timestamps are server-local, no clock synchronisation between the server and the client is required; clock offset and skew between the two machines do not affect the measurement. The first received reply only seeds prevRttSampleUs - jitter starts being computed from the second reply.

Packet loss

Each outgoing probe is recorded in a ring buffer of 256 slots indexed by the packet number. A slot stores the send time, a confirmed flag, and a used flag:

struct PacketSlot
{
    /// Time the packet was sent (steady clock).
    std::chrono::time_point<std::chrono::steady_clock> sendTimestamp{};
    /// True once the matching client reply has been processed.
    bool confirmed{false};
    /// True once the slot has been written at least once.
    bool used{false};
};

When a reply arrives (a datagram which is equal to a sent probe, see Roles and data), the slot is looked up by the echoed packet number and the reply is accepted only if slot.used == true and slot.confirmed == false. This rejects:

  • duplicates - already-confirmed slots;
  • replies to packets which were never sent - slots that were never used.

Once per tick, the ring buffer is scanned to compute the current loss ratio:

  • total - number of slots that are used and older than lossTimeoutUs;
  • lost - subset of total where confirmed == false;
  • ratio = lost / total (or 0 if total == 0).

The age threshold lossTimeoutUs is adaptive and depends on a smoothed round-trip time. The smoothed RTT (SRTT) is maintained per RFC 6298 with α = 1 / 8:

SRTT  =  SRTT + (RTT_sample - SRTT) / 8

It is updated on every received reply, before the loss scan runs. The threshold itself is then:

lossTimeoutUs = max(300 ms, 5 * SRTT)

The 300 ms floor protects the metric during warm-up before SRTT is seeded and against unrealistically small RTT estimates on the loopback. The factor of five gives replies enough margin to arrive before the slot is treated as a loss; this avoids false positives on a loaded network with elevated RTT.

Slots that are too young are simply skipped - they are still in flight and contribute nothing to either side of the ratio.

Network health calculation

Health is reported as a single float in [-1, +1]:

  • positive - the network is improving relative to the previous tick;
  • negative - the network is degrading;
  • zero - stable, or warm-up (the value is always 0 until 51 probes have been sent, see Warm-up).

It is built from two improvement deltas, both following the convention “previous - current -> positive when better”:

Delta Formula Range
lossDelta prevLossRatio - currentLossRatio [-1, 1]
jitterDelta (prevJitterUs - currentJitterUs) / max(prevJitterUs, 1 ms),
clamped to [-1, 1]
[-1, 1]

The 1 ms floor in the jitter normalisation prevents sub-millisecond noise from being amplified into spurious large deltas when the network is essentially perfect.

The two deltas are combined with weights tuned for a streaming workload:

rawStatus = 0.6 * lossDelta + 0.4 * jitterDelta
rawStatus = clamp(rawStatus, -1, +1)

Packet loss carries the larger weight because it directly translates into visible artifacts in the decoded video, whereas jitter only manifests as occasional stutter through the receiver’s jitter buffer.

Scaling and smoothing network health

To ensure values are sufficiently representative for video streaming and adaptive control, the combined metric is scaled by a factor of 1000 before being clamped to [-1, +1]:

scaledStatus = clamp(rawStatus * 1000, -1, +1)

To suppress jitter and make the signal more stable for adaptive control logic, the final status is smoothed using EWMA with α = 0.03 before being stored in m_networkStatus:

smoothedStatus += (scaledStatus - smoothedStatus) * 0.03

This means each new measurement has a weight of 3%, and the previous smoothed value has a weight of 97%. The smoothing significantly reduces high-frequency noise while still preserving the ability to detect rapid network condition changes.

Warm-up

Until 51 probes have been sent (about 5 - 8 seconds) the result is forced to zero: this warm-up period gives SRTT, the loss ring, and the previous-jitter sample enough data to be meaningful. After warm-up, getNetworkStatus() returns the most recent smoothed status value computed by the server thread.

NetworkHealthChecker class description

NetworkHealthChecker class declaration

The NetworkHealthChecker class is declared in the NetworkHealthChecker.h file. All methods can be called from several threads at the same time: initAsClient(…), initAsServer(…) and stop() are executed one after another. The object must not be destroyed while another thread calls its methods. The object can not be copied or moved (it owns a UDP socket and a thread). Class declaration:

namespace rapidpixel
{
class NetworkHealthChecker
{
public:

    /// Class constructor.
    NetworkHealthChecker();

    /// Class destructor. Stops the server or the client.
    ~NetworkHealthChecker();

    /// Copy and move are not allowed.
    NetworkHealthChecker(const NetworkHealthChecker& src) = delete;
    NetworkHealthChecker(NetworkHealthChecker&& src) = delete;
    NetworkHealthChecker& operator=(const NetworkHealthChecker& src) = delete;
    NetworkHealthChecker& operator=(NetworkHealthChecker&& src) = delete;

    /// Get string of the current class version.
    static std::string getVersion();

    /// Initialize the network health checker as a client.
    bool initAsClient(uint16_t clientUdpPort);

    /// Initialize the network health checker as a server.
    bool initAsServer(uint16_t clientUdpPort,
                      std::string clientIp);

    /// Stop the network health checker.
    void stop();

    /// Check if the network health checker is initialized.
    bool isInit();

    /// Get the current network status.
    float getNetworkStatus();
};
}

getVersion method

The getVersion() method returns the version string of the NetworkHealthChecker class. Method declaration:

static std::string getVersion();

This method can be used without a NetworkHealthChecker class instance:

std::cout << "NetworkHealthChecker class version: " <<
             rapidpixel::NetworkHealthChecker::getVersion() << std::endl;

Console output:

NetworkHealthChecker class version: 1.0.3

initAsClient method

The initAsClient(…) method initializes the object as a client and starts the internal client thread that waits for probes and echoes them back. The client socket is bound to the port on all local IPv4 interfaces. The client sends every datagram of exactly 41 bytes back to its sender without changes and ignores datagrams of other sizes. Method declaration:

bool initAsClient(uint16_t clientUdpPort);
Parameter Description
clientUdpPort UDP port to bind the client socket to, from 1 to 65535.

Returns: true if the client was started, false if the object is already initialized, the port is 0, the socket can not be opened (for example the port is used by another socket) or the thread can not be started.

initAsServer method

The initAsServer(…) method initializes the object as a server and starts the internal server thread that periodically sends probes, processes replies, and computes the network health metrics. The server’s local UDP port is auto-selected: the method scans the range 10000-65535 and binds to the first free port on all local IPv4 interfaces (the ports are tried one after another, so the call takes longer if many ports are busy). The method chooses a random session ID which is sent in every probe (see Roles and data). Method declaration:

bool initAsServer(uint16_t clientUdpPort, std::string clientIp);
Parameter Description
clientUdpPort UDP port of the client to send packets to, from 1 to 65535.
clientIp IPv4 address of the client to send packets to, in dotted decimal notation (for example “127.0.0.1”). Host names are not resolved.

Returns: true if the server was started, false if the object is already initialized, a parameter is invalid, no port can be bound, the random session ID can not be generated or the thread can not be started.

stop method

The stop() method stops the internal thread (server or client), closes the UDP socket and sets the network status to 0. The method waits until the thread ends (not longer than 50 ms for the server and 100 ms for the client). After this call the object can be safely destroyed or re-initialized via initAsClient(...) or initAsServer(...). The method does nothing if the object is not initialized. Method declaration:

void stop();

isInit method

The isInit() method returns whether the object has been initialized as a server or a client. Method declaration:

bool isInit();

Returns: true if the server or the client is running, false otherwise.

getNetworkStatus method

The getNetworkStatus() method returns the most recent network health value computed by the server thread. The method is thread-safe, does not block and can be polled at any time. On the client side and if the object is not initialized it returns 0, since the client does not compute health. Method declaration:

float getNetworkStatus();

Returns: a float in [-1, +1] indicating whether the network is degrading (value < 0), stable or in warm-up (value == 0), or improving (value > 0). See Network health calculation for the formula.

Build and connect to your project

Build stand alone

The repository does not contain sub-repositories. The library which NetworkHealthChecker depends on (UdpSocket) must be cloned into the same folder as NetworkHealthChecker. The folder structure must be:

<work folder>
    NetworkHealthChecker
    UdpSocket

Typical commands to build the library and the test application (use the URLs of the repositories provided to you):

git clone <NetworkHealthChecker repository URL>
git clone <UdpSocket repository URL>
cd NetworkHealthChecker
mkdir build
cd build
cmake ..
make

On Windows use cmake --build . --config Release instead of make. The test application is built into the bin folder of the build folder. If NetworkHealthChecker is the top-level CMake project, the main CMakeLists.txt file adds the folder ../UdpSocket before the library itself. If the folder does not exist CMake stops with an error.

CMake options

Table 2 - CMake options of the repository. In the options below ${PARENT} is the prefix defined by the top-level project (empty if NetworkHealthChecker is the top-level project).

Option Default Description
${PARENT}_NETWORKHEALTHCHECKER ON Build the library.
${PARENT}_NETWORKHEALTHCHECKER_TEST ON (stand alone), OFF (included in other project) Build the test application.
${PARENT}_SUBMODULE_CACHE_OVERWRITE ON Forced rewriting of the options above in the CMake cache. The top-level project sets it to OFF when it includes NetworkHealthChecker as a sub-repository.
CMAKE_BUILD_TYPE Release (if not specified) Build type.

Connect to your project

If you want to connect the NetworkHealthChecker library to your CMake project as source code, you can follow these steps. When NetworkHealthChecker is included in another project it does not add the UdpSocket library: your project must add it. For example, if your repository has the following structure:

CMakeLists.txt
src
    CMakeLists.txt
    yourLib.h
    yourLib.cpp

Create a 3rdparty folder in your repository and add the NetworkHealthChecker and UdpSocket repositories to the 3rdparty folder (as sub-repositories or as copies of the repository folders). The new structure of your repository:

CMakeLists.txt
src
    CMakeLists.txt
    yourLib.h
    yourLib.cpp
3rdparty
    NetworkHealthChecker
    UdpSocket

Create a CMakeLists.txt file in the 3rdparty folder. The CMakeLists.txt should contain:

cmake_minimum_required(VERSION 3.13)

################################################################################
## 3RD-PARTY
## dependencies for the project
################################################################################
project(3rdparty LANGUAGES CXX)

################################################################################
## SETTINGS
## basic 3rd-party settings before use
################################################################################
# To inherit the top-level architecture when the project is used as a submodule.
SET(PARENT ${PARENT}_YOUR_PROJECT_3RDPARTY)
# Disable self-overwriting of parameters inside included subdirectories.
SET(${PARENT}_SUBMODULE_CACHE_OVERWRITE OFF CACHE BOOL "" FORCE)

################################################################################
## CONFIGURATION
## 3rd-party submodules configuration
################################################################################
SET(${PARENT}_SUBMODULE_UDP_SOCKET                      ON  CACHE BOOL "" FORCE)
if (${PARENT}_SUBMODULE_UDP_SOCKET)
    SET(${PARENT}_UDP_SOCKET                            ON  CACHE BOOL "" FORCE)
    SET(${PARENT}_UDP_SOCKET_EXAMPLES                   OFF CACHE BOOL "" FORCE)
endif()

SET(${PARENT}_SUBMODULE_NETWORKHEALTHCHECKER            ON  CACHE BOOL "" FORCE)
if (${PARENT}_SUBMODULE_NETWORKHEALTHCHECKER)
    SET(${PARENT}_NETWORKHEALTHCHECKER                  ON  CACHE BOOL "" FORCE)
    SET(${PARENT}_NETWORKHEALTHCHECKER_TEST             OFF CACHE BOOL "" FORCE)
endif()

################################################################################
## INCLUDING SUBDIRECTORIES
## Adding subdirectories according to the 3rd-party configuration
################################################################################
if (${PARENT}_SUBMODULE_UDP_SOCKET)
    add_subdirectory(UdpSocket)
endif()

if (${PARENT}_SUBMODULE_NETWORKHEALTHCHECKER)
    add_subdirectory(NetworkHealthChecker)
endif()

The 3rdparty/CMakeLists.txt file adds the UdpSocket and NetworkHealthChecker folders to your project and excludes the test application of NetworkHealthChecker and the examples of UdpSocket. Your repository’s new structure will be:

CMakeLists.txt
src
    CMakeLists.txt
    yourLib.h
    yourLib.cpp
3rdparty
    CMakeLists.txt
    NetworkHealthChecker
    UdpSocket

Next, you need to include the 3rdparty folder in the main CMakeLists.txt file of your repository. Add the following line at the end of your main CMakeLists.txt:

add_subdirectory(3rdparty)

Next, you need to include the NetworkHealthChecker library in your src/CMakeLists.txt file (the UdpSocket library is linked by NetworkHealthChecker):

target_link_libraries(${PROJECT_NAME} NetworkHealthChecker)

If the UdpSocket library is not added by your project, the compilation fails with the error UdpSocket.h: No such file or directory.

Portability

The library has no platform specific code: the network access is done by the UdpSocket library, so NetworkHealthChecker can be used on the operating systems which UdpSocket supports (Windows and Linux). The library works with IPv4 addresses. The content of a probe packet is written byte by byte and the client returns it without changes, so the byte order of the server and of the client does not matter and they can run on different CPU architectures. The network status is computed with float and double values: the results on different CPU architectures can differ in the last bits because the compiler can use fused multiply-add instructions where the CPU has them (for example on aarch64).

Test application

The test application (test/main.cpp) demonstrates how the NetworkHealthChecker library works.

#include <chrono>
#include <cstdint>
#include <iostream>
#include <string>
#include <thread>
#include "NetworkHealthChecker.h"



namespace
{
/// Period of printing the state of the network health checker, msec.
constexpr int PRINT_PERIOD_MSEC{500};
/// Maximum UDP port number.
constexpr long MAX_UDP_PORT{65535};



/// Read UDP port from the standard input.
/// @param port Read port, changed only if the method returns TRUE.
/// @return TRUE if a number from 1 to 65535 was read or FALSE if not.
bool readPort(uint16_t& port)
{
    long value{0};
    if (!(std::cin >> value) || value < 1 || value > MAX_UDP_PORT)
    {
        return false;
    }
    port = static_cast<uint16_t>(value);
    return true;
}
}



int main()
{
    std::cout << "NetworkHealthChecker v" <<
                 rapidpixel::NetworkHealthChecker::getVersion() << " test" <<
                 std::endl;

    // Set type: server or client.
    std::cout << "Select role: [s]erver / [c]lient: ";
    char role{0};
    std::cin >> role;
    const bool isServer = role == 's' || role == 'S';
    if (!isServer && role != 'c' && role != 'C')
    {
        std::cout << "Unknown role" << std::endl;
        return 1;
    }

    // NetworkHealthChecker instance.
    rapidpixel::NetworkHealthChecker checker;

    // Initialize as server.
    if (isServer)
    {
        // Get client IP and port from user input.
        // Server will send UDP packets to this endpoint.
        uint16_t clientPort{0};
        std::cout << "Client UDP port: ";
        if (!readPort(clientPort))
        {
            std::cout << "Invalid UDP port" << std::endl;
            return 1;
        }

        std::string clientIp;
        std::cout << "Client IP: ";
        if (!(std::cin >> clientIp))
        {
            std::cout << "Invalid IP" << std::endl;
            return 1;
        }

        // Init server with the client endpoint.
        if (!checker.initAsServer(clientPort, clientIp))
        {
            std::cout << "Initialization failed" << std::endl;
            return 1;
        }
        std::cout << "Server mode initialized" << std::endl;
    }
    // Initialize as client.
    else
    {
        // Get local UDP port to bind the client socket to. The client will
        // listen for packets from the server on this port and reply to them.
        uint16_t port{0};
        std::cout << "Client UDP port: ";
        if (!readPort(port))
        {
            std::cout << "Invalid UDP port" << std::endl;
            return 1;
        }

        // Init client with the local port.
        if (!checker.initAsClient(port))
        {
            std::cout << "Initialization failed" << std::endl;
            return 1;
        }
        std::cout << "Client mode initialized" << std::endl;
    }

    // Main loop. The application runs until it is stopped (Ctrl + C).
    while (checker.isInit())
    {
        if (isServer)
        {
            std::cout << "Current network status: " <<
                         checker.getNetworkStatus() << std::endl;
        }
        else
        {
            std::cout << "Client is running. Waiting for packets from the "
                         "server..." << std::endl;
        }

        // Wait.
        std::this_thread::sleep_for(
                    std::chrono::milliseconds(PRINT_PERIOD_MSEC));
    }

    return 0;
}

To run the application on Linux, execute the following commands. On Windows, just launch NetworkHealthCheckerTest.exe:

cd <application folder>
chmod +x NetworkHealthCheckerTest
./NetworkHealthCheckerTest

On startup the application prints its version and asks which mode to run in - server or client:

NetworkHealthChecker v1.0.3 test
Select role: [s]erver / [c]lient:

Type s to start in server mode, or c to start in client mode. The two modes are described below. Another answer, a UDP port which is not a number from 1 to 65535 or a failed initialization stops the application with an error message.

Client mode

After client mode is selected, the application asks for the UDP port to bind the client socket to:

NetworkHealthChecker v1.0.3 test
Select role: [s]erver / [c]lient: c
Client UDP port:

Once the port is entered, the client starts listening on that port and echoes back every probe received from the server:

NetworkHealthChecker v1.0.3 test
Select role: [s]erver / [c]lient: c
Client UDP port: 48981
Client mode initialized
Client is running. Waiting for packets from the server...

The client itself does not compute or print any network health value - that is done on the server side. Press Ctrl + C to stop the application.

Server mode

After server mode is selected, the application asks for two parameters in turn: the UDP port that the client is listening on, and the client’s IP address. The server’s own UDP port is auto-selected by initAsServer(...) - the first free port in the 10000-65535 range is used.

The UDP port that the client has bound to (where probes will be sent):

NetworkHealthChecker v1.0.3 test
Select role: [s]erver / [c]lient: s
Client UDP port:

The client’s IP address:

NetworkHealthChecker v1.0.3 test
Select role: [s]erver / [c]lient: s
Client UDP port: 48981
Client IP:

Once both parameters are entered, the application starts sending probes to the client and printing the current network health value every 500 ms. During the warm-up period (about 5 - 8 seconds) the value is forced to zero:

NetworkHealthChecker v1.0.3 test
Select role: [s]erver / [c]lient: s
Client UDP port: 48981
Client IP: 127.0.0.1
Server mode initialized
Current network status: 0
Current network status: 0
Current network status: 0

After warm-up, the value reflects the current network health - close to zero on a stable network, positive when conditions improve, negative when they degrade:

...
Current network status: 0.000359184
Current network status: 0.00388679
Current network status: -0.00282198
Current network status: -0.00266071
Current network status: 0.00438525
Current network status: 0.00305634
Current network status: -0.00137471
Current network status: 0.000525926
Current network status: -0.00067563
...

Press Ctrl + C to stop the application.


Table of contents