UDP carries discrete datagrams between transport endpoints. Its eight-byte header contains source port, destination port, length, and checksum fields. The protocol does not add TCP’s reliable ordered byte stream or its congestion-control behavior. This gives applications room to choose their own delivery policy, but it does not promise lower latency for every workload. A missing datagram can be more costly than a handshake when the application needs its contents.

When it breaks

A successful send does not prove that the destination application received or processed a message. UDP supplies no built-in acknowledgment, retransmission, duplicate suppression, or ordering guarantee. A timeout therefore cannot tell a payment client whether an operation happened. If retries are allowed, the application needs a protocol that resolves repeated requests and uncertain outcomes rather than blindly interpreting silence as failure.

Receive buffers create another failure mode even on a healthy local path. A datagram larger than the application’s supplied buffer can be truncated. The missing suffix is not kept for the next stream-style read. Linux can report MSG_TRUNC through recvmsg, allowing the application to detect this condition. Accepting the shortened bytes as a complete command or measurement can silently corrupt meaning.

UDP’s lack of built-in congestion control is a responsibility, not permission to send without limits. Applications must choose appropriate pacing, congestion behavior, message sizes, and deadlines for their deployment. Fragmentation can make a large datagram less robust, and obsolete data may be worthless after a deadline. A protocol such as QUIC can provide reliable streams over UDP; the carrier alone does not determine application reliability.

Check it quickly

Save this as check-udp.py and run python3 check-udp.py with Docker. The pinned Python 3.11.16 Linux container has no external network path or published ports. The first part exercises actual socket receive semantics. The second uses a deliberately programmed relay to withhold, repeat, and reorder messages; it does not claim to measure spontaneous packet loss:

import json,subprocess,uuid
image="python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84"
program=r"""
import json,socket

with socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as sender,socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as receiver:
    receiver.bind(('127.0.0.1',0));receiver.settimeout(.25);sender.settimeout(1);target=receiver.getsockname()
    sender.connect(target)
    for body in [b'A',b'BC',b'']:sender.send(body)
    boundaries=[receiver.recvfrom(16)[0] for _ in range(3)]
    assert boundaries==[b'A',b'BC',b'']
    sender.send(b'abcdefgh');data,ancillary,flags,peer=receiver.recvmsg(3)
    assert data==b'abc' and flags&socket.MSG_TRUNC
    try:receiver.recvfrom(16);raise AssertionError('truncated suffix survived')
    except TimeoutError:tail='discarded'
    sender.send(b'next');assert receiver.recvfrom(16)[0]==b'next'

    # A deliberate application relay changes delivery; this is not measured network loss.
    received=[];applied=[];seen=set();forwarded=[]
    with socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as relay,socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as output:
        relay.bind(('127.0.0.1',0));relay.settimeout(1);output.settimeout(1)
        sender.connect(relay.getsockname())
        for seq in [1,2,3]:sender.send(str(seq).encode())
        inputs=[relay.recvfrom(16)[0] for _ in range(3)]
        for index in [2,0,0]:
            output.sendto(inputs[index],target);forwarded.append(int(inputs[index]))
            value=int(receiver.recvfrom(16)[0]);received.append(value)
            if value not in seen:seen.add(value);applied.append(value)
        assert received==[3,1,1] and applied==[3,1] and 2 not in seen
    try:receiver.recvfrom(16);raise AssertionError('missing message arrived by itself')
    except TimeoutError:no_retransmit=True
    print(json.dumps({'datagram_lengths':[len(x) for x in boundaries],'empty_datagram_is_message':boundaries[2]==b'',
        'short_buffer':data.decode(),'truncation_flag':bool(flags&socket.MSG_TRUNC),'unread_tail':tail,
        'relay_inputs':[int(x) for x in inputs],'relay_outputs':forwarded,'receiver':received,'deduplicated':applied,
        'missing_sequence':2,'no_automatic_retransmission_during_deadline':no_retransmit}))
"""
name='xbcatm-socket-'+uuid.uuid4().hex[:10]
try:
    result=subprocess.run(['docker','run','--rm','-i','--name',name,'--network','none',
        '--memory','128m','--pids-limit','64',image,'python3','-'],input=program,
        capture_output=True,text=True,timeout=60)
    assert result.returncode==0,result.stderr
    print(json.dumps(json.loads(result.stdout),ensure_ascii=False,sort_keys=True))
finally:
    subprocess.run(['docker','rm','-f',name],capture_output=True,text=True,timeout=10)

Three sends contain A, BC, and an empty payload. Three receives return lengths one, two, and zero. Each receive consumes one datagram rather than combining adjacent sends into a stream. The empty result is a valid zero-length message, not evidence that the sender closed a connection. The sender uses UDP connect to select its peer; that API call does not turn UDP into TCP.

Next, the sender transmits eight bytes, abcdefgh, while the receiver supplies a three-byte buffer. It obtains abc and MSG_TRUNC. A subsequent receive times out instead of returning defgh; after a new next datagram is sent, that complete new message arrives. This positive control distinguishes discarded data from a receiver that simply stopped working after its first call.

The relay receives sequence identifiers 1, 2, and 3. It deliberately forwards 3, then 1, then another 1, and omits 2. The downstream socket actually receives that chosen sequence. A small application set suppresses the duplicate, leaving applied identifiers 3 and 1, but it neither restores ordering nor reconstructs the absent message. Duplicate handling solves only one of these separate problems.

Afterward, the receiver waits for another datagram and reaches its deadline. UDP has not automatically retransmitted identifier 2. The relay, rather than the kernel, manufactured this omission, so the observation should not be described as a network reliability statistic. It is a reproducible way to test how the receiving application reacts to delivery patterns the transport does not rule out.

Choose the missing semantics deliberately

A live measurement may discard an old sample and display the newest sequence, while a file transfer needs missing data recovered. An application acknowledgment can report receipt or a durable operation result, but those meanings must be specified. Define identifier scope, retry deadlines, duplicate retention, and restart behavior before using retries for actions with side effects. This fixture implements none of that broader transaction protocol.

Budget receive capacity and datagram size together, and record truncation or application-level loss separately from malformed payloads. A larger socket queue cannot repair a receive call whose buffer is too short for one message. Likewise, an increased send rate may worsen congestion rather than improve freshness. Measure useful messages delivered before their deadlines, not just bytes accepted by the local send call.

UDP is useful when its message model fits the protocol being built and the missing guarantees have explicit owners. Assess actual end-to-end timing, failure recovery, and resource use before choosing it for a latency claim. Preserved boundaries simplify some parsing, but successful application behavior still depends on what happens when a complete datagram never arrives, arrives twice, or arrives too late.

Sources