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.UNAis the first unacknowledged byte. Everything below it has been confirmed by the peer.SND.NXTis the next byte the sender intends to transmit.SND.WNDis the receive window advertised by the peer, the number of bytes the peer’s buffer can accept beyond the acknowledgement number.FlightSizeisSND.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
cwndby at most one segment, doubling the window roughly once per RTT. - In congestion avoidance,
cwndincreases by roughly one segment per RTT, approximated bySMSS * SMSS / cwndper ACK. - On a loss indication,
cwndis 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
- RFC 5681: TCP Congestion Control — IETF
- Congestion Avoidance and Control — Van Jacobson, SIGCOMM 1988
- RFC 9293: Transmission Control Protocol (TCP) — IETF
- RFC 1982: Serial Number Arithmetic — IETF
- RFC 2861: TCP Congestion Window Validation — IETF