TCP Selective Acknowledgement

The Transmission Control Protocol’s (TCP’s) cumulative acknowledgement can express only a contiguous prefix of received data: an ACK of N means “everything below N,” and nothing more. When several segments are lost from one window of data, that single number goes deaf — it stays pinned at the first hole no matter how much later data arrives safely, so the sender cannot tell which of the following segments got through and which did not. Selective Acknowledgement (SACK), specified in RFC 2018 (October 1996), fixes this by adding a TCP option in which the receiver reports the specific non-contiguous ranges of sequence space it has already buffered. The sender can then retransmit only the genuine gaps instead of blindly resending everything after the first loss, and it can keep the ACK clock running through recovery. Two follow-on standards complete the picture: RFC 2883 (July 2000) extends the option to also report duplicate data received (D-SACK), turning SACK into a diagnostic for spurious retransmission; and RFC 6675 (August 2012, obsoleting RFC 3517) specifies a conservative SACK-based loss-recovery algorithm — the “scoreboard,” the pipe estimator, and the NextSeg() decision that together let a sender fill multiple holes in a single round trip. This note is the protocol / RFC view; the Linux implementation (tcp_sacktag_write_queue, the net.ipv4.tcp_sack/tcp_dsack sysctls, the RACK reordering logic) lives in The TCP Protocol in Linux.

Mental Model — Handing the Sender a Map of the Holes

Picture the receiver’s buffer after a burst in which the network dropped a couple of segments. Under pure cumulative acknowledgement the receiver can only point at the first missing byte and say “I’m stuck here”; the islands of data that arrived beyond the gap are invisible to the sender. SACK changes the message from a single boundary into a map: “I’m still stuck at byte 5000 (that’s the cumulative ACK), and by the way I already have 6000–7000 and 8000–9000 — don’t resend those.” The sender overlays that map on its retransmission queue (its scoreboard), sees exactly two holes (5000–5999 and 7000–7999), and resends precisely those, nothing more.

flowchart TB
  subgraph WIN["One window of the receiver's sequence space"]
    direction LR
    G0["5000–5999<br/>LOST (gap)"]:::lost --> B1["6000–6999<br/>received"]:::got --> G1["7000–7999<br/>LOST (gap)"]:::lost --> B2["8000–8999<br/>received"]:::got
  end
  WIN --> CUM["Cumulative ACK = 5000<br/>(cannot describe the islands)"]
  WIN --> SACK["SACK option:<br/>block1 = 8000–9000 (most recent)<br/>block2 = 6000–7000"]
  CUM --> SND["Sender scoreboard:<br/>retransmit 5000–5999 and 7000–7999 ONLY"]
  SACK --> SND
  classDef lost fill:#fdd,stroke:#c00;
  classDef got fill:#dfd,stroke:#080;

How SACK turns an ambiguous cumulative ACK into an actionable loss map. What it shows: two segments (5000–5999 and 7000–7999) were dropped while the segments between and after them arrived. The cumulative ACK can only report 5000. The SACK option additionally reports the two received islands — with the most recently received block (8000–9000) listed first. The sender combines both to reconstruct exactly which ranges are missing. The insight to take: the cumulative ACK and the SACK blocks are complementary, not redundant — the cumulative ACK still governs what the sender may free from its buffer, while the SACK blocks are advisory hints about what is already safely buffered, letting the sender retransmit the true gaps in one round trip instead of one gap per round trip.

The Problem SACK Solves: One Loss Per Round Trip

To feel why SACK matters you have to see how badly cumulative-only recovery copes with multiple losses in one window. In classic Reno (RFC 5681), a loss is inferred from three duplicate ACKs; the sender does a fast retransmit of the one segment at the cumulative-ACK boundary and halves its window. But the duplicate ACKs all carry the same acknowledgement number, so they identify only the first hole. If a second segment in the same window was also lost, Reno learns about it only when the retransmission of the first hole is itself acknowledged — and frequently it does not learn in time, falling back to a full retransmission timeout (RTO) and a slow-start restart, which is catastrophic for throughput.

NewReno (RFC 6582) softens this: on a partial acknowledgement — an ACK that advances the cumulative boundary but does not reach the recover point marking all data outstanding when recovery began — it infers the next in-sequence segment was also lost and retransmits it, staying in fast recovery. But NewReno still retransmits at most one lost segment per round-trip time, because each partial ACK reveals only the next single hole. With three holes in a window, NewReno needs three round trips to fill them.

SACK breaks this “one hole per RTT” ceiling. Because the receiver reports all the islands it holds, the sender learns the location of every gap from a single ACK and can retransmit all of them in one round trip, subject only to the congestion window. On lossy or high-bandwidth-delay paths this is the difference between a stall and a smooth recovery. RFC 2018’s own framing: without SACK “the TCP sender can only learn about a single lost packet per round trip time.”

The Two Options: SACK-Permitted and SACK

SACK is negotiated, then used. Two distinct TCP options implement it.

SACK-Permitted (Kind 4, Length 2) is a two-byte flag sent only in the SYN and SYN-ACK of the handshake. Its mere presence says “I understand SACK.” SACK is active on the connection only if both sides send SACK-Permitted; otherwise the connection runs cumulative-only. It carries no data — it is purely a capability announcement, which is why it can only appear at connection setup.

SACK (Kind 5, variable length) is the option that actually reports data, sent on ACKs after the connection is established, whenever the receiver is holding out-of-order data. Its layout:

   +--------+--------+
   | Kind=5 | Length |
   +--------+--------+--------+--------+
   |          Left Edge of Block 1     |   (32 bits)
   +--------+--------+--------+--------+
   |          Right Edge of Block 1    |   (32 bits)
   +--------+--------+--------+--------+
   |          Left Edge of Block 2     |
   +--------+--------+--------+--------+
   |          Right Edge of Block 2    |
   +--------+--------+--------+--------+
   |                ...                |

Each SACK block is a pair of 32-bit sequence numbers describing one contiguous run of received bytes: the Left Edge is the first sequence number of the block, and the Right Edge is the sequence number immediately following the last byte of the block (an exclusive upper bound). So a block 6000–7000 means bytes 6000 through 6999 are held. Each block is 8 bytes and the option header is 2 bytes, and TCP’s total option space is 40 bytes — so a SACK option can hold at most four blocks (2 + 4×8 = 34 ≤ 40). In practice, connections almost always also run the Timestamps option, which consumes 10 bytes plus 2 padding bytes (12 total), leaving room for only three SACK blocks (12 + 2 + 3×8 = 38 ≤ 40). This ceiling of three or four blocks is a real constraint: a receiver with many scattered holes cannot report them all at once and must prioritise.

Receiver Rules

RFC 2018 pins down how the receiver fills the option:

  • The first block MUST report the most recently received data — “the SACK option SHOULD be filled out by repeating the most recently reported SACK blocks that are not subsets of a SACK block already included,” and the first block must specify the contiguous range containing the segment that triggered this very ACK. This matters because the newest island is the freshest evidence the sender has, and it is the one most likely to have caused the duplicate ACK the sender is reacting to.
  • Include as many distinct blocks as space allows, and repeat previously reported blocks in later ACKs when there is room. Repetition provides robustness: if the ACK carrying a block is itself lost, a later ACK re-reports it, so the sender’s scoreboard converges even over a lossy reverse path.
  • SACK is advisory, not a promise. The receiver may later discard data it has SACKed (for example, under memory pressure) before it has been cumulatively acknowledged — this is reneging. Consequently the cumulative ACK is the only binding contract: the sender must not free a segment from its retransmission queue merely because it was SACKed; it frees data only when the cumulative ACK passes it. RFC 2018 is explicit that after an RTO the sender should clear all SACK-recorded state, because the receiver may have reneged.

Critically, SACK does not change the meaning of the cumulative Acknowledgment field. The two coexist: the cumulative ACK still governs buffer release and window advancement exactly as in TCP Sequence Numbers and Acknowledgements; the SACK blocks are an additional, non-binding hint layered on top.

D-SACK: Reporting Duplicates (RFC 2883)

RFC 2018 reports data the receiver is missing (by implication, from the islands it holds). RFC 2883 extends the same option to also report data the receiver received more than once — a Duplicate SACK, or D-SACK. The mechanism is minimal and backward-compatible: it reuses Kind 5 and simply redefines the first SACK block, under specific conditions, to describe a duplicate rather than a gap.

A first block is a D-SACK block when either:

  1. It lies entirely below the cumulative ACK. Since the cumulative ACK already covers that range, reporting it again can only mean the receiver got that segment a second time. RFC 2883’s worked example: segments 3000–3499 and 3500–3999 both arrive but their ACKs are lost, so the sender retransmits 3000–3499; the receiver, now holding 3000–3499 twice, ACKs 4000 (cumulative) with a SACK block of 3000–3500 — a range below 4000, unmistakably a duplicate report.
  2. It is a subset of a later (second) SACK block. This form reports a duplicate that sits above the cumulative ACK, nested inside a larger island the receiver already holds.

D-SACK needs no separate negotiation — any receiver that agreed to SACK-Permitted may send D-SACK blocks. A sender that does not implement RFC 2883 simply sees a SACK block below its SND.UNA, finds nothing in its scoreboard to match, and harmlessly ignores it. That graceful degradation is why D-SACK could be deployed incrementally across the Internet.

What does the sender do with a duplicate report? It gains a retroactive audit of its own behaviour. A D-SACK tells the sender “you sent this twice,” which lets it distinguish the several reasons that can happen: the network replicated a packet; it retransmitted unnecessarily because of reordering (a segment it thought lost was merely late, so its fast retransmit was spurious); an ACK was lost, causing an RTO on data that had in fact arrived; or its RTO was too aggressive. Armed with this, a sophisticated sender can undo a congestion-window reduction it now knows was unwarranted, or raise its duplicate-ACK threshold to avoid firing fast retransmit prematurely on a reordering-prone path. D-SACK thus makes the loss-recovery loop self-correcting: it converts wasted retransmissions from silent inefficiency into an observable signal the sender can learn from.

SACK-Based Loss Recovery (RFC 6675)

RFC 2018 defines the option; RFC 6675 defines the algorithm that consumes it — a conservative recovery procedure that plugs into standard fast retransmit / fast recovery and honours congestion control. Its machinery:

The scoreboard. The sender maintains a per-connection data structure marking, for every octet in its retransmission queue, whether it has been SACKed. This is the sender’s reconstructed picture of the receiver’s buffer — the “map of the holes.”

State variables. Four sequence numbers anchor the algorithm: HighACK (highest byte cumulatively acknowledged), HighData (highest sequence number the sender has transmitted), HighRxt (highest sequence number retransmitted so far during this recovery), and RescueRxt (used for a single “rescue” retransmission that keeps the ACK clock alive when nothing else is eligible). The duplicate threshold DupThresh is 3.

IsLost(SeqNum). The heart of loss inference. A byte is deemed lost if the scoreboard shows either at least DupThresh (3) discontiguous SACKed blocks with higher sequence numbers, or more than (DupThresh − 1) × SMSS bytes SACKed above it (SMSS = sender maximum segment size). The intuition: if three later islands, or more than two segments’ worth of later data, have been selectively acknowledged while this byte has not, it almost certainly did not arrive. This is SACK’s precise, per-byte replacement for Reno’s coarse “three duplicate ACKs.”

SetPipe() and the pipe estimator. pipe is the algorithm’s estimate of how many bytes are currently in flight (outstanding in the network). SetPipe() recomputes it by walking the scoreboard from HighACK to HighData and counting a byte toward pipe if it is not SACKed and not judged lost (i.e., it is presumed still travelling), or if it has been retransmitted. pipe is what lets the sender obey packet conservation during recovery: it may inject data — new or retransmitted — only while cwnd − pipe ≥ 1 SMSS, i.e. only when the network has drained enough to make room. This is the discipline that keeps SACK recovery from bursting and re-congesting the path.

NextSeg(). Given the scoreboard, this function decides what to send next, in priority order: (1) the lowest-numbered unSACKed byte that IsLost() flags — a genuine gap, retransmitted first; (2) failing that, new data beyond HighData if the window allows, keeping the pipe full and the ACK clock ticking; (3) a lower-confidence retransmission of unSACKed data not yet flagged lost; (4) a single rescue retransmission (governed by RescueRxt) to avoid a stall when nothing else qualifies. This ordering is why a SACK sender fills all known holes in one round trip: NextSeg() keeps returning the next lost range as long as cwnd − pipe permits, rather than waiting a round trip per hole as NewReno must.

Recovery framing. On entering recovery (three duplicate ACKs, or IsLost() firing for the boundary segment), the sender records RecoveryPoint = HighData, sets ssthresh and cwnd from FlightSize/2 per RFC 5681, retransmits the first lost segment, and calls SetPipe(). Recovery ends when the cumulative ACK reaches RecoveryPoint. An RTO during recovery resets RecoveryPoint to the current HighData and the sender continues to use the (possibly stale, so revalidated) SACK information to fill gaps rather than reverting to go-back-N.

Interaction With the Retransmit Logic

SACK does not replace the fast-retransmit trigger — it sharpens it. The trigger is still three duplicate ACKs (or, more precisely under RFC 6675, the IsLost() condition on the boundary segment). What SACK changes is everything after the trigger: instead of NewReno’s “retransmit one segment, wait a round trip, learn the next hole from a partial ACK, repeat,” the SACK scoreboard already contains the location of every gap, so NextSeg() can retransmit them all as fast as cwnd − pipe allows. SACK also improves the RTO path: after a timeout the sender still knows, from retained SACK state, which segments the receiver had before the timeout, so it can avoid needlessly resending already-delivered data — a large win over cumulative-only go-back-N. The RTO computation itself (RFC 6298: SRTT, RTTVAR, RTO = SRTT + 4·RTTVAR, exponential backoff) is unchanged and owned by the retransmission-timers note.

Failure Modes and Subtleties

  • The three/four-block ceiling. With timestamps enabled (the norm), only three SACK blocks fit per ACK. A receiver holding more than three scattered islands cannot report them all at once; it reports the newest three and relies on repetition across successive ACKs to eventually convey the rest. Highly fragmented loss patterns therefore still converge more slowly than one might naively expect.

  • Reneging. Because the receiver may discard SACKed-but-not-cumulatively-acked data under memory pressure, the sender must never treat a SACK as a delivery guarantee and must retain data until the cumulative ACK covers it. A sender that frees SACKed data early will corrupt the stream if the receiver reneges.

  • Reordering masquerading as loss. On paths that reorder packets, out-of-order arrival produces duplicate ACKs and SACK blocks that look like loss, triggering spurious fast retransmits. D-SACK is the corrective feedback: the duplicate report lets the sender recognise the retransmit was unnecessary and (optionally) undo its window cut and raise its dup-ACK threshold. Modern stacks lean on time-based detection (see Alternatives) to reduce this in the first place.

  • Malicious or malformed SACK. A remote peer controls the SACK blocks it sends, and processing them touches the sender’s retransmission queue — historically an attack surface. In 2019 a set of Linux kernel vulnerabilities (the “SACK Panic” family) allowed a remote attacker to trigger a kernel crash or excessive CPU via crafted low-MSS SACK sequences, mitigated by kernel patches and by capping SACK processing.

    Uncertain The TCP Protocol in Linux, mentioned here only for context. To resolve: consult the upstream kernel security advisory / Red Hat CVE pages. #uncertain

    Verify: the exact CVE identifiers and affected/fixed Linux versions for the 2019 “SACK Panic” issues (commonly cited as CVE-2019-11477 / -11478 / -11479). Reason: not fetched from a primary advisory during this note’s research; this is a kernel-implementation concern owned by

Alternatives and Evolution

  • Cumulative-only (Reno / NewReno). Still the fallback whenever SACK-Permitted was not negotiated by both peers. Correct, universally interoperable, but limited to recovering one loss per round trip (§ above). SACK is a strict improvement wherever both ends support it, which today is essentially everywhere.

  • FACK (Forward Acknowledgement). An older refinement by Mathis and Mahdavi that used the forward-most SACKed sequence number to estimate outstanding data more aggressively than RFC 6675’s conservative pipe. It improved recovery on some paths but was fragile under reordering and has been largely superseded.

  • RACK-TLP (Recent ACKnowledgement / Tail Loss Probe). The modern direction: instead of counting duplicate ACKs, use the timing of (SACK-carried) acknowledgements to decide a segment is lost when a later-sent segment has been acknowledged and a small reordering window has elapsed. It handles reordering and tail losses (where too few segments follow the lost one to generate three duplicate ACKs) far better than dup-ACK counting. RACK builds directly on the SACK scoreboard this note describes.

    Uncertain net.ipv4.tcp_recovery in the pinned kernel — cross-link The TCP Protocol in Linux. #uncertain

    Verify: RACK-TLP is standardised in RFC 8985 (February 2021) and is the default loss-detection method in current Linux. Reason: RFC 8985 was not fetched during this note’s research and the Linux default is a kernel-config fact that decays. To resolve: fetch RFC 8985 and check

See Also