Networking · Congestion Control

Ack Clocks and the Congestion Window: How a Sender Infers Available Capacity

Why a TCP sender measures the path with returning acknowledgements instead of a timer, how the congestion window combines with the receive window, and what a stalled clock does to the estimate.

A sender that does not know the capacity of its path has two options. It can pick a rate, send at that rate, and discover the consequences when packets disappear. Or it can send a bounded amount of data, wait for evidence that the data arrived, and use the arrival of that evidence as the permission to send more. TCP chose the second, and the choice shapes everything that follows: congestion control in TCP is not a rate controller at all, it is a window controller, and the rate is whatever the window divided by the round trip happens to produce.

The mechanism that converts acknowledgements into permission is the congestion window, a sender-side limit on how much unacknowledged data may be outstanding. It is a number the sender invented for itself. Nothing in the packet header carries it, no receiver enforces it, and no router can see it. It exists only to stop the sender from putting more traffic into a path than the path has demonstrated it can absorb.

The four variables a sender actually tracks

Congestion control is bookkeeping over four quantities. Three are sequence numbers or window counts, one is derived.

  • SND.UNA is the first unacknowledged byte. Everything below it has been confirmed by the peer.
  • SND.NXT is the next byte the sender intends to transmit.
  • SND.WND is the receive window advertised by the peer, the number of bytes the peer’s buffer can accept beyond the acknowledgement number.
  • FlightSize is SND.NXT - SND.UNA, the bytes sent but not yet acknowledged.

The usable send window is the smaller of the congestion window and the advertised receive window: min(cwnd, SND.WND) - FlightSize bytes may be handed to the network right now. That minimum is the whole interface between flow control and congestion control, and conflating them is the most common conceptual error in the subject. The receive window protects the receiver’s buffer. The congestion window protects the path. A receiver with a large buffer advertises a large window and applies no congestion brake at all; a path with a small bandwidth-delay product is throttled by cwnd even when the receiver would happily accept ten times more.

Sequence numbers wrap. TCP’s are 32-bit, so a naive SND.NXT > SND.UNA comparison is wrong twice per 4 GiB of stream, and SND.WND can legitimately reach 2^30 with window scaling. Comparisons must use serial-number arithmetic: a precedes b when (int32_t)(a - b) < 0, the rule from RFC 1982. An implementation that uses ordinary unsigned comparison will eventually stall for one window’s worth of data and then recover, producing a symptom that looks exactly like packet loss.

Self-clocking: the ACK as a metronome

Jacobson’s 1988 description of the behaviour is the clearest: a TCP connection is a self-clocking system, because each acknowledgement reports that one packet left the network and therefore that one packet may enter it. If the sender keeps exactly cwnd bytes in flight and sends a new segment for every segment acknowledged, the packets arrive at the bottleneck spaced exactly as the bottleneck released them. The sender’s transmission pattern becomes a copy of the bottleneck’s service pattern, which is the property that makes the loop stable. No timer is involved. The clock is the returning ACK stream, and its frequency is the path’s delivery rate in packets per second.

This is why window-based control tolerates path changes that rate-based control does not. If the bottleneck capacity halves, the ACK stream halves in frequency and the sender’s rate halves with it, without any explicit measurement of capacity. If a new flow appears, the queue grows, the RTT grows, and the same window now covers more time, so the rate falls. The feedback is implicit and continuous.

The cost of the design is that the loop only works while ACKs keep coming. Stop the ACK stream and the clock stops with it, leaving the sender with no information and no way to distinguish a slow network from a broken one.

Growth rules and what the window means

The window is not static. It rises while the path appears to have room and falls when it does not. At the coarsest level the rules of RFC 5681 are:

  • In slow start, each ACK that acknowledges new data increases cwnd by at most one segment, doubling the window roughly once per RTT.
  • In congestion avoidance, cwnd increases by roughly one segment per RTT, approximated by SMSS * SMSS / cwnd per ACK.
  • On a loss indication, cwnd is reduced, and the reduction is multiplicative rather than subtractive.

The exact arithmetic lives in slow start and additive increase. What matters here is the unit: cwnd counts bytes, not packets. A sender that increments per ACK without accounting for how many bytes that ACK covers grows at a rate determined by the receiver’s ACK behaviour rather than by the path. Delayed ACKs, which acknowledge two segments at once, would halve the growth rate if the increment were one segment per ACK. RFC 5681 limits the slow-start increment to at most SMSS bytes per ACK, and RFC 3465’s appropriate byte counting relaxes that to min(bytes acknowledged, 2*SMSS) so that a delayed ACK does not halve the growth rate, while also allowing congestion avoidance to credit the full acknowledged byte count rather than a fixed fraction.

A minimal accounting loop

The following is the skeleton of the send path, stripped of retransmission, options, and SACK. It shows where the two windows meet and how the clock drives transmission.

#include <stdint.h>

struct sender {
    uint32_t snd_una;   /* first unacknowledged sequence number */
    uint32_t snd_nxt;   /* next sequence number to use */
    uint32_t snd_wnd;   /* receive window advertised by the peer */
    uint32_t cwnd;      /* congestion window, bytes */
    uint32_t smss;      /* sender maximum segment size, bytes */
};

/* Serial-number comparison: true when a comes before b modulo 2^32. */
static int before(uint32_t a, uint32_t b) {
    return (int32_t)(a - b) < 0;
}

/* Bytes the sender may still put on the wire. */
static uint32_t usable(const struct sender *s) {
    uint32_t limit = s->cwnd < s->snd_wnd ? s->cwnd : s->snd_wnd;
    uint32_t flight = s->snd_nxt - s->snd_una;
    return limit > flight ? limit - flight : 0;
}

/* Called once per arriving ACK that advances SND.UNA by acked bytes. */
static void on_ack(struct sender *s, uint32_t ack, uint32_t acked, int in_slow_start) {
    if (before(ack, s->snd_una) || before(s->snd_nxt, ack))
        return;                       /* duplicate or impossible ACK */
    s->snd_una = ack;
    if (acked == 0)
        return;
    if (in_slow_start)
        s->cwnd += acked < 2 * s->smss ? acked : 2 * s->smss;
    else
        s->cwnd += (s->smss * s->smss) / s->cwnd;   /* ~1 SMSS per RTT */
}

Two details in that code are load-bearing. The before helper makes the ACK validity check wrap-safe; without it, a valid ACK after a wrap is discarded as old and the connection hangs. And the growth in congestion avoidance is per ACK but divided by the current window, so the increase per RTT is approximately constant regardless of how many ACKs arrive.

What the clock cannot tell you

Ack clocking assumes that ACK arrivals carry timing information about the path. Four situations break that assumption, and each produces a specific pathology.

Application-limited sending. If the application has nothing to send, the window is never full and the ACK stream is sparse. The clock still ticks, but it is measuring the application, not the network. A sender in this state should not grow its window, because a window larger than what is in flight carries no evidence about the path; RFC 2861’s congestion window validation limits growth to the data actually sent and decays a window that has gone unused. Congestion avoidance without that restriction produces a burst when the application finally has data to send.

ACK compression. If a queue upstream of the receiver drains in a burst, several ACKs arrive back-to-back. The sender reads this as a sudden increase in delivery rate and, if it responds by sending a burst, reinforces the pattern. The pathology is most visible in paths that aggregate connections or in receivers that batch ACKs aggressively.

Delayed and stretched ACKs. A receiver may wait for a second segment before acknowledging, and may delay up to a bounded interval. Both reduce the ACK rate for a fixed data rate, so the sender grows more slowly in wall-clock terms and the loop’s effective gain changes. This is why the congestion-avoidance increment is expressed in bytes rather than in ACKs.

Loss of the ACK itself. A dropped ACK costs the sender a clock tick and forces a retransmission that was not needed, because the data arrived. Since ACK loss and data loss look identical at the sender, the sender must treat them the same, which is one source of the ambiguity examined in duplicate ACK recovery.

There is also a structural limit that no amount of tuning removes. A window of cwnd bytes on a path with round-trip time RTT yields at most cwnd/RTT bytes per second. To fill a 1 Gbit/s path with a 100 ms round trip, the window must reach about 12.5 MB, which requires window scaling and a receiver buffer to match. A path whose window cannot grow that large is not congestion-limited, it is window-limited, and no congestion control algorithm can fix that.

Where the two windows diverge

Flow control and congestion control share a variable but answer different questions, and the divergence is worth stating precisely because it explains a class of real failures.

A receiver advertises SND.WND based on free buffer space. A sender with a fast path and a slow receiver will be limited by SND.WND and will never probe the path’s capacity, so its congestion window stays small and the connection runs below capacity with no loss signal at all. A sender with a slow path and a fast receiver will be limited by cwnd, which shrinks on every loss, and the receiver’s window is irrelevant. The first case is a throughput problem that looks like an application problem; the second is where congestion control actually operates.

Window shrinking adds a rule that is easy to get wrong: a receiver may reduce its advertised window over time, and a sender must not retract data it has already sent. RFC 9293 instructs a receiver not to move the right window edge to the left, but requires the sender to stay robust when one does: it stops sending new data while the usable window is negative or zero, keeps retransmitting within the advertised window, and probes a closed window rather than stalling forever.

The window’s growth is also what makes the loop’s stability fragile in the presence of large queues. An oversized buffer absorbs the sender’s excess for a long time, so the ACK clock keeps ticking at full rate while the queue fills, and the sender learns about the problem only when the buffer overflows. That delay between cause and signal is the subject of buffer sizing and tail drop, and it is the reason window-based control alone cannot bound queueing latency.

References

  1. RFC 5681: TCP Congestion Control — IETF
  2. Congestion Avoidance and Control — Van Jacobson, SIGCOMM 1988
  3. RFC 9293: Transmission Control Protocol (TCP) — IETF
  4. RFC 1982: Serial Number Arithmetic — IETF
  5. RFC 2861: TCP Congestion Window Validation — IETF