TCP Slow-Start Window Growth: A Three-Level Comparison of Textbooks, RFCs, and the Linux Kernel
AI translation notice: This English version was translated from the original Chinese article with AI assistance. Some technical wording may differ from the original; please refer to the cited RFCs and source code for authoritative details.
While studying for my exam a few days ago, I came across this question:

You can view it here: 2025 exam question
The answer's explanation said:
Since the current congestion window is smaller than the threshold, the connection is in the slow-start phase. For every ACK received, the congestion window increases by 1 MSS, so it grows from 2000 B to 3000 B.
This small detail was different from my long-standing assumption that the congestion window doubles after every RTT. What was going on? (As it turned out, I had simply failed to read the textbook carefully 🤪.) TCP also uses cumulative acknowledgments, so would that not prevent the window from doubling?
With this question in mind, I asked a very capable classmate. Their answer was:
The sender grows
cwndaccording to the number of bytes acknowledged, not the number of ACKs. (When one ACK acknowledges one MSS, you can also think of it as growingcwndaccording to the ACK count.) The exact implementation depends on the protocol design. Many networking questions describe an idealized situation.
This introduced yet another explanation: growth according to acknowledged bytes? I became curious about how cwnd actually grows during TCP slow start, so I decided to investigate it myself.
Starting with “Doubling Every RTT”
Let us begin with the description most familiar from networking textbooks and exam questions.
The initial congestion window cwnd is usually set to 1 MSS (the latest RFCs allow more MSS). The slow-start algorithm says that for every acknowledgment of a new segment, cwnd increases by 1. In this way, after each transmission round—one round-trip time, or RTT—cwnd doubles, producing exponential growth as transmission rounds pass. To prevent cwnd from growing without limit, a slow-start threshold called ssthresh is also set. When cwnd grows to ssthresh, slow start stops and the congestion-avoidance algorithm takes over.
It can be summarized like this:
- During slow start: for every ACK,
cwnd += 1 MSS. - If every data segment in one RTT triggers its own ACK,
cwndroughly doubles by the end of that RTT. - During congestion avoidance: growth slows to approximately 1 MSS per RTT.
Assume that the threshold is 16. The example looks like this:
| Round | Initial cwnd | ACKs received during one RTT | cwnd at the end of the RTT |
|---|---|---|---|
| 0 | 1 MSS | 1 | 2 MSS |
| 1 | 2 MSS | 2 | 4 MSS |
| 2 | 4 MSS | 4 | 8 MSS |
| 3 | 8 MSS | 8 | 16 MSS <-(threshold reached) |
| 4 | 16 MSS | 16 | 17 MSS <-(congestion avoidance) |
So cwnd grows exponentially with each RTT. Even though modern TCP uses cumulative acknowledgments, its growth trend is still approximately exponential.
RFC 5681: How the Standard Defines Slow Start
You can find the original text here.
The slow start algorithm is used when cwnd < ssthresh, while the congestion avoidance algorithm is used when cwnd > ssthresh. When cwnd and ssthresh are equal, the sender may use either slow start or congestion avoidance.
During slow start, a TCP increments cwnd by at most SMSS bytes for each ACK received that cumulatively acknowledges new data. Slow start ends when cwnd exceeds ssthresh (or, optionally, when it reaches it, as noted above) or when congestion is observed. While traditionally TCP implementations have increased cwnd by precisely SMSS bytes upon receipt of an ACK covering new data, we RECOMMEND that TCP implementations increase cwnd, per:
cwnd += min (N, SMSS) (2)
where N is the number of previously unacknowledged bytes acknowledged in the incoming ACK. This adjustment is part of Appropriate Byte Counting [RFC3465] and provides robustness against misbehaving receivers that may attempt to induce a sender to artificially inflate cwnd using a mechanism known as "ACK Division" [SCWA99]. ACK Division consists of a receiver sending multiple ACKs for a single TCP data segment, each acknowledging only a portion of its data. A TCP that increments cwnd by SMSS for each such ACK will inappropriately inflate the amount of data injected into the network.
The core meaning of Section 3.1 of RFC 5681 is:
During slow start, for each ACK that cumulatively acknowledges new data, TCP increases
cwndby the number of acknowledged bytes, but by no more thanSMSS(the sender's MSS) bytes.
This can be broken down into three key phrases:
for each ACK received: an ACK is the trigger.cumulatively acknowledges new data: the ACK must cumulatively acknowledge new data.cwnd += min (N, SMSS):Nis the number of bytes newly acknowledged by the ACK, capped at the sender's MSS.
Why Is It Designed This Way?
The original text explains the reason as well:
This adjustment is part of Appropriate Byte Counting (ABC, RFC 3465), which makes TCP more robust against misbehaving receivers. An abnormal receiver might use a mechanism called ACK Division to induce the sender to inflate
cwnd. In ACK Division, the receiver sends multiple ACKs for one TCP data segment, with each ACK acknowledging only a portion of that segment. If a TCP implementation increasescwndby SMSS for every such ACK, it will inappropriately increase the amount of data injected into the network.
In short, RFC 5681 wants to prevent the receiver from tricking the sender into expanding its congestion window by “splitting ACKs.”
Suppose the sender transmits one data segment of size SMSS. Normally, the receiver should acknowledge the segment with one ACK. During slow start, the sender then increases cwnd by 1 SMSS, which is fine.
But if the receiver deliberately splits the acknowledgment into several small ACKs:
ACK 1: acknowledges the first 200 bytes
ACK 2: acknowledges the first 400 bytes
ACK 3: acknowledges the first 600 bytes
...
If the sender still follows the rule “increase cwnd by 1 SMSS for every ACK,” the same data segment will trigger multiple window increases. Only one segment was successfully transmitted through the network, but the sender behaves as though it can inject a great deal more data. cwnd is therefore inflated abnormally.
This is why RFC 5681 recommends:
cwnd += min(N, SMSS)
The meaning is:
- If an ACK acknowledges only a small amount of new data,
cwndincreases only by that amount. - If an ACK acknowledges a large amount of data, the increase for that ACK is still capped at
1 SMSS.
This preserves the normal slow-start behavior—one fully acknowledged MSS produces roughly one MSS of growth—while preventing ACK Division from maliciously inflating the window.
However, this is only the rule described by RFC 5681 / RFC 3465 from a byte-counting perspective. When we later look at the Linux kernel implementation, we will see a different viewpoint: Linux does not directly increase the window according to the number of newly acknowledged bytes. It counts the number of “fully acknowledged segments.” That difference is the most interesting part of this article.
Linux Kernel Implementation: Why Does It Look Different Again?
The source is in the Linux kernel's net/ipv4/tcp_cong.c; search for tcp_slow_start().
Here is the core function and the comment immediately before it:
/* Slow start is used when congestion window is no greater than the slow start
* threshold. We base on RFC2581 and also handle stretch ACKs properly.
* We do not implement RFC3465 Appropriate Byte Counting (ABC) per se but
* something better;) a packet is only considered (s)acked in its entirety to
* defend the ACK attacks described in the RFC. Slow start processes a stretch
* ACK of degree N as if N acks of degree 1 are received back to back except
* ABC caps N to 2. Slow start exits when cwnd grows over ssthresh and
* returns the leftover acks to adjust cwnd in congestion avoidance mode.
*/
__bpf_kfunc u32 tcp_slow_start(struct tcp_sock *tp, u32 acked)
{
u32 cwnd = min(tcp_snd_cwnd(tp) + acked, tp->snd_ssthresh);
acked -= cwnd - tcp_snd_cwnd(tp);
tcp_snd_cwnd_set(tp, min(cwnd, tp->snd_cwnd_clamp));
return acked;
}
The comment contains a great deal of information. Here is a copy version so we can see it more clearly:
Slow start is used when the congestion window is no greater than the slow-start threshold. Linux is based on RFC 2581 and handles stretch ACKs correctly.
Linux does not strictly implement Appropriate Byte Counting (ABC) from RFC 3465. Instead, it uses another approach: a packet is counted as acknowledged only when it has been fully ACKed or SACKed. This defends against the ACK attacks described in the RFC.
For a stretch ACK with degree N, Linux's slow start treats it as though N ordinary ACKs of degree 1 had arrived back to back. ABC, however, caps N at 2.
When this growth would make
cwndcrossssthresh, slow start stops at the threshold and returns the unused ACK count to congestion avoidance so that congestion avoidance can continue adjusting the window.
Several terms need explanation first:
SACK: Selective Acknowledgment, which tells the sender which non-contiguous data blocks have already arrived.(s)acked: the notation used in the kernel comment, meaning ACKed or SACKed.stretch ACK: an ACK that acknowledges multiple segments at once instead of only one. For example, a receiver using delayed acknowledgment may wait for two or more TCP segments before sending one ACK.degree N: this stretch ACK acknowledges N complete segments at once.
acked Is Not Measured in Bytes
The most important detail is that acked in this Linux function does not mean “how many bytes were acknowledged.” It means “how many complete segments were acknowledged.”
RFC 5681 / RFC 3465 tends to describe the rule in bytes:
cwnd += min(N, SMSS)
Here, N is the number of bytes newly acknowledged by the ACK.
But the implementation here is closer to:
cwnd += acked
Here, acked is the number of newly acknowledged complete segments. Linux itself maintains snd_cwnd in segment units, so + acked does not add a byte count directly to the window; it adds the number of complete segments acknowledged.
This explains why the kernel code looks different from the RFC formula:
- RFC discussions often describe
cwndgrowth in bytes. - Linux's
ackedhere is the number of fully acknowledged data segments. - Therefore,
tcp_snd_cwnd(tp) + ackedmeans that the window grows by the number of complete segments.
Under ideal conditions, this is equivalent to the textbook rule “for every ACK, cwnd += 1 MSS.” If an ACK acknowledges exactly one complete MSS, then acked = 1 and the window increases by one segment.
Why Does This Also Prevent ACK Division?
Linux's defense is different from the RFC's. It does not treat a fraction of a segment as one effective “complete-segment acknowledgment.” Only when a packet is fully acknowledged does it count toward acked. Therefore, even if a malicious receiver splits one segment into multiple small ACKs, acked cannot become multiple complete segments.
That is what the comment means when it says:
Linux does not strictly implement ABC, but does something “better.”
“Better” here can be understood as defending against ACK Division at the granularity of complete-segment acknowledgments rather than at byte granularity. This can provide higher throughput, avoids floating-point and byte-level calculations, and transitions more smoothly into congestion avoidance. The code walkthrough below will explain that transition line by line.
Linux Is More Aggressive with Stretch ACKs
Now consider a stretch ACK.
Suppose one ACK acknowledges three complete segments at once. Linux treats it as though three ordinary ACKs arrived consecutively. RFC 3465's ABC, by contrast, limits a single increase during slow start; its experimental recommendation says that L should not exceed 2*SMSS. Therefore, under a stretch ACK, Linux is somewhat more aggressive than ABC.
This also explains the comment:
Linux treats a stretch ACK that acknowledges N segments as N ordinary ACKs arriving back to back, while ABC limits that number to 2.
The Code in Detail: Reading tcp_slow_start() Line by Line
Let us return to the function:
__bpf_kfunc u32 tcp_slow_start(struct tcp_sock *tp, u32 acked)
{
u32 cwnd = min(tcp_snd_cwnd(tp) + acked, tp->snd_ssthresh);
acked -= cwnd - tcp_snd_cwnd(tp);
tcp_snd_cwnd_set(tp, min(cwnd, tp->snd_cwnd_clamp));
return acked;
}
First, several variables:
tp: the control block for the current TCP connection, containing the connection's congestion-control state.tcp_snd_cwnd(tp): the current sender congestion window, measured in complete segments.tp->snd_ssthresh: the slow-start threshold. Slow start grows only until approximately this threshold, after which congestion avoidance takes over.tp->snd_cwnd_clamp: the hard upper limit for the congestion window, preventingcwndfrom exceeding the maximum allowed by the system.acked: the number of complete segments newly acknowledged by this ACK.
The first line:
u32 cwnd = min(tcp_snd_cwnd(tp) + acked, tp->snd_ssthresh);
calculates how far the window can grow during slow start:
new cwnd = min(current cwnd + newly acknowledged complete segments, slow-start threshold)
In other words, during slow start the window really does increase according to acked, but it cannot grow beyond ssthresh.
The second line:
acked -= cwnd - tcp_snd_cwnd(tp);
calculates how much of this ACK's acknowledgment count has already been consumed by slow start.
cwnd - tcp_snd_cwnd(tp) is the amount by which slow start actually increased the window this time. Subtracting that amount from acked leaves the acknowledgment count that slow start did not use.
What Happens When ssthresh Is Reached?
Consider this example:
current cwnd = 8
ssthresh = 10
acked = 5
Without the threshold, the window could grow from 8 to 13.
But slow start can only grow as far as ssthresh = 10:
actual new cwnd = min(8 + 5, 10) = 10
So slow start consumes only 2 of the 5 acknowledged segments:
actual growth = 10 - 8 = 2
remaining acked = 5 - 2 = 3
The third line:
tcp_snd_cwnd_set(tp, min(cwnd, tp->snd_cwnd_clamp));
actually updates snd_cwnd in the TCP control block. The value is also clamped against snd_cwnd_clamp, ensuring that the window does not exceed the system's configured upper limit.
The last line:
return acked;
returns the part of acked that slow start did not consume.
If slow start has not reached ssthresh, the function usually returns 0, meaning that the entire acknowledgment count was used to grow the slow-start window.
If this ACK crosses the slow-start threshold, the function returns the remaining acked so that the congestion-avoidance logic can use it. This prevents the portion of the same ACK that exceeds the slow-start threshold from being wasted; it is handed smoothly to congestion avoidance instead.
The Linux code can therefore be summarized as:
During slow start:
cwnd grows by the number of newly acknowledged complete segments, acked;
but it cannot grow beyond ssthresh;
if this ACK reaches ssthresh, the leftover acked is passed to congestion avoidance.
This is why the formulas can look different after reading the RFC and then the Linux source: RFC 5681 / RFC 3465 describe the rule mainly in terms of acknowledged bytes, while Linux implements this function in terms of complete segments.
One Slow Start, Three Descriptions
The following table summarizes the comparison:
| Dimension | Textbook model | RFC 5681 / RFC 3465 | Linux kernel |
|---|---|---|---|
| Core viewpoint | Build intuition | Define protocol boundaries | Engineering implementation |
| Growth basis | Number of ACKs | Bytes newly acknowledged by an ACK,N |
Complete segments newly acknowledged,acked |
| Slow-start formula | Each ACK for a segment gives roughly+1 MSS |
cwnd += min(N, SMSS) |
cwnd += acked |
| Unit | Usually abstracted in MSS | Bytes | Complete segments |
| Stretch ACK acknowledging 3 segments | Usually not discussed | RFC 5681: at most+1 SMSS; RFC 3465 ABC can tune L but not beyond 2*SMSS |
Approximately+3 segments |
| ACK Division defense | Not covered | Limit growth withmin(N, SMSS) |
Count only fully acknowledged segments inacked |
| Ideal one-ACK-per-packet case | Roughly doubles within one RTT | Roughly doubles within one RTT | Roughly doubles within one RTT |
As we can see, the textbook formula and the Linux implementation are equivalent for the idealized slow-start case. But there are many details and design considerations behind that similarity. If not for a classmate's one-sentence question, I might never have learned about them. If you know more about this topic, feel free to share it in the comments!
If you want to run an experiment yourself, you can refer to this Linux cwnd experiment.
References
- Author:Mark Xu
- Copyright:All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating otherwise.
