UDP doesn’t guarantee packet order, so you need to add your own sequence numbers to ensure data arrives in the correct sequence.

Let’s see this in action. Imagine we have a simple application sending a stream of data over UDP. Without sequence numbers, a network device might reorder packets, leading to garbled data at the receiver.

Here’s a conceptual Rust example of how you might implement this:

use std::net::UdpSocket;
use std::time::Duration;
use std::thread;
use std::sync::{Arc, Mutex};
use std::collections::BinaryHeap;
use std::cmp::Reverse;

const BUFFER_SIZE: usize = 1024;
const PORT: &str = "127.0.0.1:8080";

// A packet with a sequence number
#[derive(Debug, Clone, PartialEq, Eq)]
struct SequencedPacket {
    sequence_number: u32,
    data: Vec<u8>,
}

// For BinaryHeap to act as a min-heap
impl Ord for SequencedPacket {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        other.sequence_number.cmp(&self.sequence_number) // Reverse order for min-heap
    }
}

impl PartialOrd for SequencedPacket {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

fn main() -> std::io::Result<()> {
    let socket = UdpSocket::bind(PORT)?;
    println!("Listening on {}", PORT);

    let received_packets = Arc::new(Mutex::new(BinaryHeap::new()));
    let mut expected_sequence_number = 0;

    // Receiver thread
    let receiver_socket = socket.try_clone()?;
    let received_packets_clone = Arc::clone(&received_packets);
    let receiver_handle = thread::spawn(move || {
        let mut buffer = [0; BUFFER_SIZE];
        loop {
            match receiver_socket.recv_from(&mut buffer) {
                Ok((amt, src)) => {
                    if amt == 0 { continue; }
                    // In a real scenario, you'd deserialize the packet.
                    // For this example, we'll assume a simple structure.
                    // Let's say the first 4 bytes are the sequence number.
                    if amt >= 4 {
                        let seq_bytes = &buffer[0..4];
                        let seq_num = u32::from_be_bytes(seq_bytes.try_into().unwrap());
                        let data = buffer[4..amt].to_vec();

                        let packet = SequencedPacket {
                            sequence_number: seq_num,
                            data,
                        };

                        println!("Received packet: Seq={} ({} bytes)", seq_num, amt);

                        let mut packets = received_packets_clone.lock().unwrap();
                        packets.push(packet);
                    }
                }
                Err(e) => {
                    eprintln!("Receiver error: {}", e);
                    break;
                }
            }
        }
    });

    // Processing thread
    let processing_handle = thread::spawn(move || {
        loop {
            thread::sleep(Duration::from_millis(10)); // Poll for new packets
            let mut packets = received_packets.lock().unwrap();

            while let Some(packet) = packets.peek() {
                if packet.sequence_number == expected_sequence_number {
                    let mut processed_packet = packets.pop().unwrap(); // Take it out
                    println!("Processing packet: Seq={} ({} bytes)", processed_packet.sequence_number, processed_packet.data.len());
                    // Here you would process processed_packet.data
                    // For example, append to a buffer, display to user, etc.
                    expected_sequence_number += 1;
                } else {
                    // Packet not in order, wait for the next one
                    break;
                }
            }
        }
    });

    // In a real app, you'd have a sender thread too.
    // For demonstration, let's simulate sending some packets.
    let sender_socket = socket.try_clone()?;
    thread::spawn(move || {
        for i in 0..10 {
            let mut packet_data = i.to_string().into_bytes();
            let mut sequenced_packet_bytes = i.to_be_bytes().to_vec(); // Sequence number
            sequenced_packet_bytes.append(&mut packet_data);

            // Simulate sending out of order (e.g., by sending quickly)
            // In a real network, this would happen naturally.
            if i == 3 { // Send packet 3 a bit later to simulate delay
                thread::sleep(Duration::from_millis(50));
            }
            if i == 5 { // Send packet 5 even later
                thread::sleep(Duration::from_millis(100));
            }

            match sender_socket.send_to(&sequenced_packet_bytes, PORT) {
                Ok(_) => println!("Sent packet: Seq={}", i),
                Err(e) => eprintln!("Sender error: {}", e),
            }
            thread::sleep(Duration::from_millis(20)); // Small delay between sends
        }
    });


    receiver_handle.join().unwrap();
    processing_handle.join().unwrap();

    Ok(())
}

This code sets up a UDP socket and two threads: one for receiving and one for processing. The receiver thread collects incoming packets and stores them in a BinaryHeap (which acts as a min-heap because we’ve implemented Ord in reverse for SequencedPacket). The processing thread continuously checks the heap. It only "processes" a packet (by popping it from the heap and incrementing expected_sequence_number) if its sequence number matches the one it’s expecting. If the next packet in the heap has a higher sequence number, it means a packet is missing or out of order, so the processing thread waits.

The core problem UDP solves is efficient, low-overhead datagram delivery. It doesn’t care about the order of those datagrams, nor does it care if some are lost. Think of it like sending postcards: each postcard is a datagram, and they might arrive in any order, or some might never arrive. UDP’s strength is its speed and minimal overhead because it skips all the complex mechanisms that ensure reliability and order, which are handled by higher-level protocols like TCP.

When you build applications that need ordered delivery, like streaming video, online gaming, or file transfers, you can’t rely on UDP alone. You have to add that logic yourself. This typically involves assigning a monotonically increasing sequence number to each outgoing packet. On the receiving end, you buffer incoming packets and reassemble them based on these sequence numbers. You’ll need a mechanism to track the "next expected" sequence number and hold onto any packets that arrive out of order until their predecessors are received.

The BinaryHeap in the example is a common way to manage out-of-order packets. It keeps the packet with the lowest sequence number at the "top" (or root). The processing loop peeks at the top element. If it’s the one we expect, we pop it and advance our expected sequence number. If it’s not, we leave it in the heap and wait, hoping the missing packet arrives soon. This is a form of buffering.

A crucial aspect of this reordering logic is deciding how long to hold onto out-of-order packets. If you hold them indefinitely, you might run into memory issues or introduce unacceptable latency. If you don’t hold them long enough, you might discard packets that would have arrived shortly after their predecessors. This requires tuning based on expected network conditions and application requirements. You might also implement a timeout for packets that seem to be lost entirely, rather than just delayed.

The "surprise" here is that while UDP is often presented as a simpler alternative to TCP, implementing reliable, ordered delivery on top of UDP is often more complex than people realize, requiring careful state management, buffering, and retransmission strategies if you were to add those as well.

The next challenge you’ll face is handling packet loss, which UDP also doesn’t guarantee against.

Want structured learning?

Take the full Udp course →