This is an excerpt. The full article includes a live interactive network ARQ simulator — adjust packet loss rates and Round Trip Times (RTT) in real time, choose between Selective Repeat and Go-Back-N, transmit message frames, and watch the sliding window resend dropped packets. Read the full interactive version →
The Network Dilemma: TCP vs. UDP
When designing network architectures, we traditionally choose between two standard transport layer protocols:
- TCP (Transmission Control Protocol): Guarantees in-order, error-free delivery. However, it suffers from heavy connection handshake overhead, head-of-line blocking (where a single dropped packet stalls all subsequent packets), and rigid congestion control algorithms.
- UDP (User Datagram Protocol): A lightweight, connectionless protocol with minimal packet overhead. It sends packets immediately without waiting, but it does not guarantee delivery, packet order, or duplicate prevention.
For real-time, high-throughput systems (such as multiplayer game servers, streaming platforms, or WebRTC data sync channels), we need both the speed of UDP and the reliability of TCP.
Reliable UDP (RUDP) solves this. By implementing reliability mechanisms in user space on top of raw UDP sockets, developers can choose which packets require guaranteed delivery, customize timeout behaviors, and avoid head-of-line blocking.
1. Custom Packet Header Structures
To enforce reliability over a connectionless UDP socket, we must append a custom protocol header to every payload.
This custom header typically contains the following fields:
- Sequence Number (32 bits): Identifies the exact order of the packet so the receiver can reassemble them correctly.
- Acknowledgment Number (32 bits): Confirms the sequence numbers of successfully received packets.
-
Flags (8 bits): Controls packet types, such as connection setup (
SYN), data payloads (DAT), or confirmations (ACK/NACK). - Checksum (16 bits): Validates packet integrity to detect data corruption during transit.
┌───────────────────────────────────────────────────────────────┐
│ CUSTOM RUDP PACKET │
├───────────────────────┬───────────────────────┬───────────────┤
│ Sequence Number (32b) │ ACK Number (32b) │ Flags (8b) │
├───────────────────────┴───────────────────────┴───────────────┤
│ Data Payload (Variable) │
└───────────────────────────────────────────────────────────────┘
2. Sliding Window Flow Control
Sending one packet and waiting for its ACK before sending the next (Stop-and-Wait) is very slow. To maximize bandwidth, we use a Sliding Window.
The sender maintains a "window" of allowed, unacknowledged packets (e.g., sequence numbers 0 to 3). As long as the window isn't full, the sender transmits packets continuously.
When the receiver ACKs the oldest packet in the window (the base), the window slides forward, allowing new packets to be sent. This keeps the network link saturated and increases overall data throughput.
[ACKed] [Sent, UnACKed] [Unsent]
┌───────┐ ┌───────────────┐ ┌─────────┐
... │ 0 1 │ │ 2 3 4 5 │ │ 6 7 │ ...
└───────┘ └───────────────┘ └─────────┘
▲ ▲
│ │
Window Base Next Seq Num
3. ARQ Error Recovery: GBN vs. SR
When a packet is lost in transit, the protocol must decide how to recover. There are two main strategies:
Go-Back-N (GBN)
In Go-Back-N, the receiver only accepts packets in strict, sequential order. If packet 2 is lost, the receiver discards all subsequent packets (3, 4, 5), even if they arrive safely.
The sender's timeout is triggered for packet 2, and the sender must retransmit packet 2 and all following packets in the window (2, 3, 4, 5). This is simple to implement but wastes bandwidth on lossy networks.
Selective Repeat (SR)
In Selective Repeat, the receiver accepts out-of-order packets and buffers them. If packet 2 is lost but 3, 4, and 5 arrive, the receiver keeps them and sends ACKs for them.
The sender detects that only packet 2 is missing and retransmits only packet 2. Once packet 2 arrives, the receiver merges it with the buffered packets and delivers them in order to the application, minimizing retransmission traffic.
TypeScript RUDP Sender Implementation
Here is a clean, modular TypeScript implementation mapping sliding window boundaries, Selective Repeat timers, and packet serialization:
export interface UDPPacket {
seq: number;
flags: { SYN: boolean; ACK: boolean; DAT: boolean };
checksum: number;
payload: string;
}
export class SlidingWindowSender {
private windowSize: number = 4;
private nextSeqNum: number = 0;
private base: number = 0;
private sendBuffer: Map<number, UDPPacket> = new Map();
private ackedPackets: Set<number> = new Set();
private timers: Map<number, NodeJS.Timeout> = new Map();
private readonly TIMEOUT_MS = 1500;
constructor(private socketSend: (packet: UDPPacket) => void) {}
/**
* Appends payload to buffer and attempts transmission.
*/
public send(payload: string): void {
const packet: UDPPacket = {
seq: this.nextSeqNum,
flags: { SYN: false, ACK: false, DAT: true },
checksum: this.calculateChecksum(payload),
payload
};
this.sendBuffer.set(this.nextSeqNum, packet);
this.nextSeqNum++;
this.tryTransmit();
}
private tryTransmit(): void {
// Transmit packets falling within sliding window limits
while (this.nextSeqNum < this.base + this.windowSize && this.sendBuffer.has(this.nextSeqNum)) {
const packet = this.sendBuffer.get(this.nextSeqNum)!;
this.transmitPacket(packet);
}
}
private transmitPacket(packet: UDPPacket): void {
this.socketSend(packet);
this.startTimer(packet.seq);
}
private startTimer(seq: number): void {
if (this.timers.has(seq)) return;
const timer = setTimeout(() => {
// Timeout triggered: retransmit packet
const packet = this.sendBuffer.get(seq);
if (packet && !this.ackedPackets.has(seq)) {
this.transmitPacket(packet);
}
}, this.TIMEOUT_MS);
this.timers.set(seq, timer);
}
/**
* Receiver notifies sender of an Acknowledgment (ACK)
*/
public handleAck(ackSeq: number): void {
this.ackedPackets.add(ackSeq);
// Stop timer
if (this.timers.has(ackSeq)) {
clearTimeout(this.timers.get(ackSeq)!);
this.timers.delete(ackSeq);
}
// Slide window base forward if the oldest packet in the window was ACKed
if (ackSeq === this.base) {
while (this.ackedPackets.has(this.base)) {
this.base++;
}
this.tryTransmit();
}
}
private calculateChecksum(data: string): number {
let sum = 0;
for (let i = 0; i < data.length; i++) {
sum += data.charCodeAt(i);
}
return sum % 65535;
}
}
Engineering Takeaways
- RUDP is the foundation of modern web protocols: Technologies like QUIC (which powers HTTP/3) and WebRTC are built directly on top of UDP to avoid TCP handshakes and head-of-line blocking.
- Selective Repeat is essential on lossy networks: While Go-Back-N is simpler, it wastes massive amounts of bandwidth under heavy packet loss by retransmitting valid data.
- User-space implementation enables flexibility: Moving transport-layer logic out of the OS kernel allows developers to optimize congestion controls for specific applications.
The full article features a live 2D network packet simulator — adjust network loss and latency sliders, select Selective Repeat or Go-Back-N, and watch sliding window frames, packet drops, and ACK flows resolve in real time.
Written by Ebenezer Akinseinde — Software Developer & AI Automations Engineer.
Top comments (6)
Really like that you actually implemented selective repeat instead of just describing it. One thing that might be off in SlidingWindowSender though: send() bumps nextSeqNum right after buffering the packet, then tryTransmit() loops while sendBuffer.has(this.nextSeqNum), which is the slot you just moved past, so the very first packet never seems to leave. And if it did enter the loop, nothing inside advances nextSeqNum, so it would resend the same packet forever. Feels like you need a separate pointer for "next sequence to assign" versus "next to transmit", with tryTransmit walking the second one. Or am I misreading how the buffer is keyed?
@nazar-boyko
So sorry for the delayed reply.
Really, you're 100% right. That's for noting that!
The original implementation of tryTransmit() is buggy because it checks this.sendBuffer.has(this.nextSeqNum) immediately after nextSeqNum is incremented in send(). Since I just incremented it, it's looking for a buffer slot that doesn't exist yet, so the loop doesn't execute and the packet never gets transmitted. And as you pointed out, even if the loop were entered, there was nothing inside it advancing the sequence number, which would trigger an infinite loop.
To fix this, I need a separate pointer - like nextToSend - to track what is being transmitted while nextSeqNum solely tracks buffer slot assignments.
I've also fixed a secondary issue in the timeout retransmission where we need to delete the fired timer from the map so subsequent timeouts can be re-scheduled correctly.
Below is the corrected implementation:
export class SlidingWindowSender {
private windowSize: number = 4;
private nextSeqNum: number = 0; // Next sequence to assign
private nextToSend: number = 0; // Next sequence to transmit
private base: number = 0;
private sendBuffer: Map = new Map();
private ackedPackets: Set = new Set();
private timers: Map = new Map();
private readonly TIMEOUT_MS = 1500;
constructor(private socketSend: (packet: UDPPacket) => void) {}
/**
Appends payload to buffer and attempts transmission.
*/
public send(payload: string): void {
const packet: UDPPacket = {
seq: this.nextSeqNum,
flags: { SYN: false, ACK: false, DAT: true },
checksum: this.calculateChecksum(payload),
payload
};
this.sendBuffer.set(this.nextSeqNum, packet);
this.nextSeqNum++;
this.tryTransmit();
}
private tryTransmit(): void {
// Transmit packets falling within sliding window limits
while (this.nextToSend < this.base + this.windowSize && this.sendBuffer.has(this.nextToSend)) {
const packet = this.sendBuffer.get(this.nextToSend)!;
this.transmitPacket(packet);
this.nextToSend++; // Advance the transmit pointer
}
}
private transmitPacket(packet: UDPPacket): void {
this.socketSend(packet);
this.startTimer(packet.seq);
}
private startTimer(seq: number): void {
if (this.timers.has(seq)) return;
}
/**
Receiver notifies sender of an Acknowledgment (ACK)
*/
public handleAck(ackSeq: number): void {
this.ackedPackets.add(ackSeq);
// Stop timer
if (this.timers.has(ackSeq)) {
clearTimeout(this.timers.get(ackSeq)!);
this.timers.delete(ackSeq);
}
// Slide window base forward if the oldest packet in the window was ACKed
if (ackSeq === this.base) {
while (this.ackedPackets.has(this.base)) {
this.base++;
}
this.tryTransmit();
}
}
private calculateChecksum(data: string): number {
let sum = 0;
for (let i = 0; i < data.length; i++) {
sum += data.charCodeAt(i);
}
return sum % 65535;
}
}
I've updated the website code with this fix. Thanks for taking the time to read so closely and explain it.
RUDP is just one of many ways to add reliability to UDP.
CoAP (the Constrained Application Protocol) is also based on UDP but uses a different approach wherein packets/requests are optionally marked as confirmable (require an ACK). The flexibility of that approach is really nice. CoAP does other nice things, like all the familiar capabilities of HTTP, plus built-in server sent events via Observe and native binary payloads (text/json encoding is optional). The difference in bandwidth, performance and capability are pretty amazing (it's also pretty simple to run on the backend, unlike QUIC/HTTP3).
@mlhpdx
Well stated, Lee. CoAP is a fantastic example of a protocol that gets this right, especially for IoT and resource-constrained environments.
The optional reliability model (Confirmable CON vs. Non-confirmable NON messages) is really elegant. It gives you the flexibility to choose reliability on a per-message basis, which is perfect when you have a mix of critical state updates and transient sensor readings.
Also, the "Observe" option (RFC 7641) is such a clean way to handle subscriptions without the overhead of WebSockets or HTTP long-polling. And you're spot on about backend simplicity. Running HTTP/3 or raw QUIC in production requires handling complex TLS 1.3 handshakes, connection migration, and heavy congestion control states. CoAP, by contrast, is light enough to implement from scratch in a few hundred lines of code.
Thanks for highlighting it.
How do you handle packet reordering with Selective Repeat ARQ, especially when dealing with high-latency networks?
@frank_signorini
Firstly, I really apologize for the delayed reply.
Great question. Packet reordering is one of the trickiest edge cases to handle in Selective Repeat, especially when high latency (and thus a large Bandwidth-Delay Product) is involved.
Here's how you typically handle it:
Out-of-Order Buffering & Reassembly: The receiver maintains a sliding window of size $W$. If packet $N+2$ arrives before packet $N$, the receiver places $N+2$ in an out-of-order buffer/map and sends an ACK for $N+2$ immediately. The receiver's window base doesn't slide forward yet. Once $N$ and $N+1$ finally arrive, the receiver merges them with the buffered $N+2$, delivers the sequential block to the application, and slides the window forward.
The $2 \times W$ Sequence Space Rule: On high-latency networks, packets can get delayed so long that the sequence numbers wrap around. To prevent the receiver from confusing a delayed packet from a previous window cycle with a new packet in the current window, your sequence number space (say, modulo $M$) must be at least twice the size of your window ($M \ge 2W$).
Handling 'Old' Packets: If a packet arrives with a sequence number below the receiver's current window base (an old packet that was already received and delivered), the receiver must re-send an ACK for it and discard the payload. This is crucial because if that packet's original ACK got lost in transit, the sender will keep retransmitting it and its window will remain stuck.
Memory Constraints for High BDP: With high latency, you need a larger window size to keep the network pipeline full. Because of this, the receiver must be configured with a maximum buffer memory limit. If packet reordering is severe, the buffer can fill up with out-of-order packets. If it hits the limit, you have to drop newly arrived out-of-order packets until the missing base packet arrives and frees up memory.
I hope this was helpful.