Split View: TCP 혼잡 제어를 눈으로 보기 — CUBIC과 BBRv3, 그리고 브라우저에서 도는 gVisor 네트스택
TCP 혼잡 제어를 눈으로 보기 — CUBIC과 BBRv3, 그리고 브라우저에서 도는 gVisor 네트스택
- 들어가며 — 브라우저 탭에서 도는 두 개의 TCP 스택
- 골격 — slow start, congestion avoidance, fast retransmit
- CUBIC이 최적화하는 것
- BBR이 다르게 보는 것 — 손실이 아니라 경로 모델
- 엔지니어가 실제로 보는 증상 세 가지
- 실제 호스트에서 cwnd와 재전송을 보는 법
- 시뮬레이터가 보여 주지 못하는 것
- 마치며 — 혼잡 제어는 여전히 추정 문제다
- 참고 자료
들어가며 — 브라우저 탭에서 도는 두 개의 TCP 스택
2026년 7월 28일 해커뉴스에 Simulating TCP loss and congestion in browser using Go/WASM이라는 링크가 올라왔습니다. 링크를 열면 ccsim이라는 페이지가 나오는데, 여기서 도는 것이 데모용 애니메이션이 아닙니다. 소스 저장소의 설명이 정확합니다 — gVisor의 사용자 공간 TCP/IP 스택(gvisor.dev/gvisor/pkg/tcpip) 두 벌을 각각 송신자와 수신자로 세우고, 그 사이에 토큰 버킷 방식의 병목 링크 모델을 끼워 넣은 이벤트 구동 시뮬레이터를 통째로 WebAssembly로 빌드해 브라우저 워커에서 실행합니다.
만든 쪽의 기술 블로그에 동기가 적혀 있습니다. 이들은 터널링 인프라에서 패킷을 주입하는 데 gVisor 네트스택을 쓰는데, 공동 창업자의 불안정한 가정용 회선에서 프로브가 계속 죽었습니다. 손실이 있는 링크에서 CUBIC이 잘 못 한다는 심증은 있었고, 그것을 확인하려다 보니 BBRv3를 netstack 위에 직접 구현하고 검증 하네스까지 만들게 됐다는 흐름입니다. 시뮬레이터는 그 검증 하네스의 부산물입니다.
브라우저 데모라고 얕볼 물건이 아닌 이유는 저장소의 검증 문서에 있습니다. CUBIC 윈도 성장 곡선이 RFC 9438의 삼차 함수에 C=0.410, R²=0.9999로 맞고, 손실 응답 함수는 규격값의 0.68~0.95배 안에 들며, RED의 마킹 곡선은 정확한 램프 확률질량함수와 카이제곱 검정으로 비교합니다. 게다가 네이티브 빌드와 WASM 빌드의 출력 샘플 스트림이 바이트 단위로 동일합니다. 교보재이면서 회귀 테스트인 셈입니다.
이 글은 이 시뮬레이터를 후크로 삼되, 실제 가치는 그 아래 있는 알고리듬 쪽에 두겠습니다. 혼잡 제어는 30년 넘게 같은 문제를 풀어 왔고, 화면에서 본 톱니 모양이 실제 서버에서 어떤 증상으로 나타나는지가 결국 우리가 알아야 할 것이기 때문입니다.
골격 — slow start, congestion avoidance, fast retransmit
TCP 송신자는 두 개의 창을 동시에 봅니다. 하나는 수신자가 광고한 수신 윈도(rwnd)로 "받는 쪽이 감당할 수 있는 양"이고, 다른 하나가 혼잡 윈도(cwnd)로 "네트워크가 감당할 수 있다고 송신자가 추정한 양"입니다. 실제로 내보낼 수 있는 양은 둘 중 작은 쪽입니다. 혼잡 제어 알고리듬이란 결국 cwnd를 언제 얼마나 올리고 내릴지에 대한 규칙 모음입니다.
Slow start는 이름과 달리 지수적으로 빠릅니다. 연결이 시작되면 cwnd를 초기 윈도(요즘 리눅스는 RFC 6928에 따라 10 MSS)로 두고, ACK가 하나 올 때마다 cwnd를 1 MSS씩 늘립니다. ACK 하나가 새 세그먼트 두 개를 풀어 주므로 RTT마다 cwnd가 두 배가 됩니다. 경로 용량을 전혀 모르는 상태에서 로그 시간에 근접해 가기 위한 탐색입니다. 이 단계는 ssthresh에 도달하거나 손실이 감지될 때까지 계속됩니다.
Congestion avoidance는 그다음입니다. 이제 용량 근처에 있다고 보고, 증가 속도를 RTT당 대략 1 MSS로 낮춥니다. 손실을 만나면 cwnd를 곱셈으로 줄입니다. 이 AIMD(additive increase, multiplicative decrease) 구조가 인터넷을 붕괴에서 구한 핵심입니다 — 여러 흐름이 같은 병목을 공유할 때 AIMD는 공정성 쪽으로 수렴하는 반면, 곱셈 증가는 그렇지 않습니다.
Fast retransmit은 손실을 재전송 타임아웃(RTO)보다 훨씬 빨리 잡기 위한 장치입니다. 수신자는 순서에서 벗어난 세그먼트를 받으면 마지막으로 연속 수신한 지점을 반복해서 ACK합니다. 송신자가 같은 ACK를 세 번 중복으로 받으면 RTO를 기다리지 않고 그 세그먼트를 즉시 재전송하고, fast recovery로 들어가 cwnd를 절반 정도로 낮춘 뒤 계속 보냅니다. RTO를 기다렸다면 최소 수백 ms를 날렸을 자리입니다.
여기서 한 가지는 짚고 넘어가야 합니다. 위 문단은 교과서의 Reno 기준이고, 지금 리눅스가 실제로 하는 손실 감지는 RACK-TLP(RFC 8985)입니다. 중복 ACK 개수를 세는 대신 각 세그먼트의 전송 시각과 SACK로 확인된 도착 순서를 비교해, "이 세그먼트보다 나중에 보낸 것이 이미 도착했고 재정렬 윈도도 지났다"면 손실로 판정합니다. 중복 ACK 세 개라는 규칙이 재정렬에 약하고 꼬리 손실(마지막 세그먼트가 사라져 중복 ACK 자체가 안 생기는 경우)에 무력했기 때문입니다. ccsim도 RACK/TLP를 켜고 돌립니다.
CUBIC이 최적화하는 것
리눅스·윈도우·애플 스택의 기본값인 CUBIC은 RFC 9438으로 2023년 8월에 표준화됐습니다(RFC 8312를 폐기). 이름 그대로 윈도 성장을 삼차 함수로 정의합니다.
W_cubic(t) = C * (t - K)^3 + W_max
t : 마지막 혼잡 이벤트 이후 경과 시간(초)
W_max : 혼잡 이벤트 직전의 윈도 크기
C : 스케일링 상수 (기본 0.4)
K : cbrt(W_max * (1 - beta) / C), beta = 0.7
이 곡선의 모양이 설계 의도 전부입니다. 손실 직후에는 W_max 근처까지 오목하게 빠르게 회복하고, W_max에 다다르면 거의 평평해져 그 부근에 오래 머물며 탐색하고, 아무 일도 없으면 그 위로 볼록하게 가속해 새 용량을 찾아 나섭니다. 감소 계수는 Reno의 0.5가 아니라 0.7이라 손실 한 번에 잃는 양도 적습니다.
무엇을 최적화한 것이냐면, 두 가지입니다.
첫째, 고 BDP 링크의 이용률입니다. Reno의 RTT당 1 MSS 증가는 대역폭이 크고 지연이 긴 경로에서 절망적으로 느립니다. CUBIC의 증가는 경과 시간의 함수라서 RTT에 직접 묶여 있지 않습니다.
둘째, 바로 그 이유로 RTT 공정성입니다. Reno 계열에서는 RTT가 짧은 흐름이 같은 시간 동안 더 많이 증가해 대역폭을 더 가져갑니다. 시간의 함수인 CUBIC은 이 편향이 훨씬 작습니다.
대신 CUBIC이 포기한 것이 명확합니다. CUBIC의 혼잡 신호는 여전히 패킷 손실 하나뿐입니다. 큐가 얼마나 차 있는지, 지연이 얼마나 늘었는지는 보지 않습니다. 손실이 날 때까지 밀어 올리고, 손실이 나면 줄입니다. 이 한 문장에서 뒤에 나올 두 가지 증상이 모두 파생됩니다.
BBR이 다르게 보는 것 — 손실이 아니라 경로 모델
BBR은 질문을 바꿉니다. "언제 손실이 나는가" 대신 "이 경로의 병목 대역폭과 최소 왕복 지연은 얼마인가"를 계속 추정하고, 그 곱(BDP) 근처를 유지하려 합니다. ccsim의 화면 설명이 이 대비를 잘 압축합니다 — 1 BDP가 최적점이고, CUBIC은 손실 쪽으로 넘어가면서 그 지점을 찾고, BBR은 경로를 추정해 그 지점 근처에 머무릅니다.
이 최적점은 Kleinrock이 1979년에 정리한 것이기도 합니다. in-flight가 BDP보다 적으면 링크가 놀고, BDP보다 많으면 초과분은 전부 병목 큐에 쌓여 지연만 늘릴 뿐 처리량은 그대로입니다. 문제는 대역폭과 최소 지연을 동시에 측정할 수 없다는 데 있습니다 — 대역폭 최대치를 재려면 큐를 만들어야 하고, 최소 지연을 재려면 큐를 비워야 합니다. 그래서 BBR은 두 값을 번갈아 가며 잽니다.
ccsim이 구현한 BBRv3는 draft-ietf-ccwg-bbr의 상태 기계를 따릅니다(현재 06판, 상태는 Experimental). 저장소 문서에 실린 상수까지 옮기면 이렇습니다.
- Startup — pacing 게인 710/256(약 2.77), cwnd 게인 2.0. 대역폭 추정치가 더 이상 늘지 않을 때까지 밀어 올립니다.
- Drain — pacing 게인 88/256(약 0.34). Startup에서 만들어 놓은 큐를 비웁니다.
- ProbeBW — DOWN(232/256) → CRUISE(1.0) → REFILL(1.0) → UP(1.25)의 순환. 대부분의 시간을 여기서 보내며, UP 구간에서만 잠깐 더 밀어 대역폭이 늘었는지 확인합니다.
- ProbeRTT — cwnd 게인 0.5로 약 200ms. 최소 5초 간격으로 스케줄되며, 큐를 비워
min_rtt필터를 갱신합니다.min_rtt자체는 10초 창으로 유지됩니다.
여기에 BBRv3가 v1 대비 추가한 것이 손실과 ECN에 대한 명시적 응답 경계입니다. inflight_hi, inflight_lo, bw_lo 같은 상한을 두어, 경로가 실제로 손실이나 CE 마킹을 돌려주면 모델을 그대로 밀어붙이지 않고 물러섭니다. BBRv1이 CUBIC과 공존할 때 지나치게 공격적이라는 비판을 받았던 부분에 대한 답입니다.
그리고 BBR을 실제로 쓰려면 반드시 따라오는 것이 pacing입니다. cwnd만 열어 두고 버스트로 내보내면 병목 큐가 순간적으로 터지므로, 추정한 대역폭에 맞춰 패킷을 시간축에 고르게 뿌려야 합니다. 리눅스에서는 fq qdisc나 커널 내부 pacing이 이 일을 합니다. ccsim은 이것을 CC 안이 아니라 송신 통합 계층에 두고, min(pacing_rate * 1ms, 64KB) 크기의 양자 단위로 가상 클록 타이머를 통해 내보냅니다.
엔지니어가 실제로 보는 증상 세 가지
여기까지가 이론이고, 아래가 티켓으로 올라오는 모양입니다. ccsim 저장소의 시나리오 측정치를 그대로 옮기면 세 증상이 숫자로 보입니다.
| 시나리오 | 조건 | 결과 |
|---|---|---|
| cubic-single | 100Mbps, 손실 없음 | 96.5 Mbps, cwnd 감축 3회, RTO 0회 |
| random-loss | 랜덤 손실 1% | CUBIC 3.0 Mbps 대 BBRv3 57 Mbps |
| bufferbloat | 깊은 taildrop 버퍼, base RTT 30ms | CUBIC 정상 상태 srtt 1,070ms 대 BBRv3 32ms |
| ecn-codel | CoDel + ECN | CE 마킹 654회, 드롭·재전송 0회, srtt는 base의 2.1배 이하 |
| rate-step | 대역폭 계단 변화 | 즉시 24 Mbps 전달, max-bw 필터 2주기 후 새 BDP의 1.07배 |
| fairness | 100Mbps 한 링크에 CUBIC과 BBR 공존 | 링크 96% 사용, 분배는 약 33% 대 67% |
증상 1 — 손실 링크에서의 처리량 붕괴. 1% 랜덤 손실에서 CUBIC이 3.0 Mbps로 주저앉습니다. 100Mbps 링크에서 3%입니다. 이건 버그가 아니라 손실 기반 설계의 직접적 귀결이고, Mathis 근사식이 그대로 예측합니다.
throughput ~ MSS / (RTT * sqrt(p)) * C
MSS 1448B, RTT 40ms, p = 0.01, C ~ 1.22
-> 1.22 * 1448 * 8 / (0.04 * 0.1) ~ 3.5 Mbps
식에서 손실률이 제곱근으로 들어간다는 점이 중요합니다. 손실이 0.1%에서 1%로 열 배 늘면 처리량은 3분의 1이 아니라 약 3.16분의 1로 떨어집니다. 그리고 이 손실이 혼잡 때문인지 무선 간섭이나 회선 품질 때문인지 CUBIC은 구분하지 못합니다. 무선 구간이나 장거리 회선을 지나는 흐름이 이유 없이 느린 전형적인 원인이고, BBR이 57 Mbps를 내는 이유이기도 합니다 — BBR은 손실이 나도 전달률 추정치가 유지되면 모델을 크게 흔들지 않습니다.
증상 2 — bufferbloat. 깊은 버퍼가 달린 병목에서 CUBIC의 정상 상태 srtt가 1,070ms입니다. 경로의 실제 왕복 지연은 30ms입니다. 1초가 전부 큐 대기 시간입니다. 여기서 손실은 오히려 거의 나지 않습니다 — 버퍼가 깊으니 드롭 대신 지연으로 흡수되기 때문입니다. 그래서 CUBIC은 "아무 문제 없다"고 판단하고 그 상태로 안정화됩니다. 같은 조건에서 BBRv3는 32ms를 유지합니다. base RTT가 30ms이니 큐가 사실상 비어 있다는 뜻입니다.
증상 3 — 버퍼를 키우면 더 나빠진다. 위의 두 문단을 합치면 자동으로 나옵니다. 손실 기반 CC의 정상 상태 큐 점유량은 버퍼 크기가 결정합니다. 버퍼를 2배로 키우면 정상 상태 지연도 대략 2배가 됩니다. "패킷 드롭이 보이니 버퍼를 늘리자"는 직관은 드롭을 지연으로 바꿀 뿐이고, 대화형 트래픽 입장에서는 더 나쁜 거래입니다. 라우터 버퍼가 싸지면서 이 실수가 인터넷 전역에 퍼진 것이 bufferbloat 문제의 역사입니다.
처방은 버퍼 크기가 아니라 큐 규율입니다. 표의 ecn-codel 행이 그 그림입니다 — CoDel이 큐 지연을 보고 미리 CE를 마킹하니 드롭도 재전송도 0회인데 srtt는 base의 2.1배 이하로 눌립니다. 손실이라는 신호를 지연 신호로 대체한 것이고, 리눅스라면 fq_codel이나 cake가 이 일을 합니다. 소비자용 라우터라면 이쪽이 사실상 유일한 실용적 해법입니다.
마지막 행의 공정성 숫자도 실무적으로 의미가 있습니다. CUBIC과 BBR이 같은 링크를 공유할 때 33% 대 67%로 갈립니다. 링크는 96%까지 차 있으니 낭비는 없지만 균등하지도 않습니다. BBRv3가 v1보다 온순해졌다고는 해도, 서버 함대의 일부만 BBR로 바꾸면 나머지 CUBIC 흐름이 손해를 본다는 뜻입니다. 전환은 부분이 아니라 도메인 단위로 하는 편이 낫습니다.
실제 호스트에서 cwnd와 재전송을 보는 법
시뮬레이터가 예쁘게 그려 주는 값들은 리눅스 호스트에서도 다 볼 수 있습니다. 계측 코드는 필요 없습니다.
# 현재 기본 알고리듬과 사용 가능한 목록
sysctl net.ipv4.tcp_congestion_control
sysctl net.ipv4.tcp_available_congestion_control
# BBR을 쓰려면 모듈과 pacing qdisc가 같이 필요하다
sudo modprobe tcp_bbr
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
tc qdisc show dev eth0 # fq 또는 fq_codel 인지 확인
# 소켓별 상태: cwnd, ssthresh, rtt, 재전송, 전달률, pacing rate가 전부 여기 나온다
ss -tin state established
# cubic wscale:7,7 rto:236 rtt:35.6/2.1 mss:1448 cwnd:73 ssthresh:52
# bytes_sent:... retrans:0/14 delivery_rate:76.4Mbps busy:... pacing_rate:...
# 특정 목적지만
ss -tin dst 203.0.113.10
# 호스트 전체 재전송 카운터 (증가분만)
nstat -az | grep -Ei 'TcpRetransSegs|TcpExtTCPLostRetransmit|TcpExtTCPTimeouts|TcpExtTCPFastRetrans'
ss -tin 출력에서 실무적으로 가장 중요한 세 값은 이렇습니다.
cwnd— 패킷 단위입니다.cwnd * mss를rtt로 나누면 그 순간의 상한 처리량이 나옵니다.delivery_rate와 비교하면 애플리케이션이 못 채우고 있는지(app-limited) 네트워크가 막고 있는지 갈립니다.retrans:X/Y— 앞이 현재 미해결 재전송, 뒤가 누적입니다. 뒤 숫자가bytes_sent대비 몇 퍼센트인지가 앞 절의 손실률p입니다.rtt:A/B— A가 평활화된 RTT, B가 변동(mdev)입니다. A가 경로의 최소 RTT보다 훨씬 크고 재전송은 거의 없다면 그것이 bufferbloat의 서명입니다.
주기적으로 찍어 cwnd 톱니를 눈으로 보고 싶다면 이 정도로 충분합니다.
# 200ms 간격으로 cwnd/rtt/retrans만 뽑아 시계열로 남긴다
while :; do
date +%s.%N | tr -d '\n'
ss -tin dst 203.0.113.10 | tr '\n' ' ' \
| grep -oE 'cwnd:[0-9]+|rtt:[0-9.]+|retrans:[0-9]+/[0-9]+' | tr '\n' ' '
echo
sleep 0.2
done
커널 이벤트 레벨로 내려가려면 tracepoint가 이미 준비돼 있습니다.
# 혼잡 상태 전이와 재전송을 이벤트로 본다
sudo bpftrace -e '
tracepoint:tcp:tcp_retransmit_skb { @retrans[comm] = count(); }
tracepoint:tcp:tcp_cong_state_set { @state[args->cong_state] = count(); }'
# 재현 환경 만들기: 손실 1% + 지연 20ms + 얕은 큐
sudo tc qdisc replace dev eth0 root netem loss 1% delay 20ms limit 100
# bufferbloat 재현: 큐를 아주 깊게
sudo tc qdisc replace dev eth0 root netem delay 20ms limit 10000
# 처방 확인: AQM으로 교체
sudo tc qdisc replace dev eth0 root fq_codel
netem으로 손실을 만들어 놓고 같은 파일을 CUBIC과 BBR로 각각 전송해 보면, 앞 절 표의 3.0 Mbps 대 57 Mbps가 어떤 종류의 차이인지 30초 안에 체감됩니다. 리눅스 네트워크 스택 내부 구조 전반은 리눅스 네트워킹 내부 편에, TCP 자체의 동작은 TCP 심층 분석 편에 따로 정리해 두었습니다.
시뮬레이터가 보여 주지 못하는 것
교보재로서 ccsim의 강점은 명확합니다. 실제 프로토콜 구현을 돌리므로 SACK 스코어보드, RACK 재정렬 판정, ECN 에코 같은 것이 근사가 아니라 실물이고, 결정적(deterministic)이라 같은 시드로 같은 결과가 나옵니다. 슬라이더를 움직이면 시뮬레이션이 실행 중인 채로 파라미터가 바뀌므로, 대역폭이 갑자기 떨어질 때 두 알고리듬이 얼마나 다르게 반응하는지가 한 화면에서 보입니다.
동시에 시뮬레이션의 경계도 분명합니다.
- 흐름이 하나거나 둘입니다. 실제 병목에는 수천 개의 흐름이 있고, 그 중 대부분은 짧아서 slow start를 벗어나지도 못합니다. 인터넷 트래픽에서 정상 상태 동작이 차지하는 비중은 생각보다 작습니다.
- 병목이 하나이고 고정입니다. 실제 경로에서는 병목이 이동하고, 무선 구간의 용량은 초 단위로 요동칩니다.
- 미들박스가 없습니다. ECN 비트를 지우는 장비, SACK을 깨뜨리는 방화벽, TCP를 대신 종단해 버리는 프록시는 실제 인터넷에서 흔합니다. BBR을 켰는데 기대한 효과가 안 나는 원인의 상당수가 여기 있습니다.
- CPU와 인터럽트 처리가 없습니다. GRO, TSO, NIC 큐, 그리고 어느 코어가 소프트IRQ를 처리하는지가 실제로는 지연 분포를 크게 바꿉니다.
그래서 시뮬레이터에서 얻을 것은 "우리 환경에서 BBR이 몇 Mbps를 낸다"가 아니라 이 알고리듬은 어떤 신호에 반응하고 무엇에는 반응하지 않는가입니다. 그 감각이 있으면 ss -tin 출력 한 줄에서 읽어 낼 수 있는 것이 확실히 달라집니다.
마치며 — 혼잡 제어는 여전히 추정 문제다
정리하면 이렇습니다.
- cwnd는 네트워크 용량에 대한 송신자의 추정치입니다. 알고리듬의 차이는 무엇을 증거로 삼아 그 추정을 갱신하느냐의 차이입니다.
- CUBIC은 손실 하나만 증거로 씁니다. 그래서 무선 손실을 혼잡으로 오인하고, 깊은 버퍼 앞에서는 증거를 못 받아 큐를 가득 채운 채 안정화됩니다.
- BBR은 전달률과 최소 RTT로 경로 모델을 세우고 BDP 근처를 노립니다. BBRv3는 여기에 손실·ECN 응답 경계를 더해 공존성을 개선했지만, CUBIC과 나눠 쓸 때의 분배는 여전히 균등하지 않습니다.
- 버퍼를 키우는 것은 드롭을 지연으로 바꾸는 거래입니다. 답은 큐 크기가 아니라
fq_codel같은 AQM입니다. - 이 전부는
ss -tin,nstat,tc netem세 도구로 자기 호스트에서 재현하고 관측할 수 있습니다.
혼잡 제어를 오래 안 들여다본 채로 "느리다"는 티켓을 받으면 대개 대역폭을 의심하게 됩니다. 실제로는 cwnd가 손실률에 갇혀 있거나, RTT가 큐 때문에 부풀어 있는 경우가 더 많습니다. 두 경우 모두 대역폭을 늘려도 아무것도 나아지지 않습니다.
참고 자료
- Simulating TCP loss and congestion in browser using Go/WASM — 해커뉴스 (item 49088098, 2026-07-28)
- ccsim — CC Lab 데모
- apoxy-dev/ccsim — 소스, 검증 문서, 시나리오 측정치
- BBRv3 for gVisor's netstack — Apoxy 기술 블로그
- RFC 9438 — CUBIC for Fast and Long-Distance Networks (2023-08, Standards Track)
- draft-ietf-ccwg-bbr — BBR Congestion Control (IETF, Experimental)
- RFC 8985 — The RACK-TLP Loss Detection Algorithm for TCP
- RFC 6928 — Increasing TCP's Initial Window
- gVisor netstack — pkg/tcpip
- TCP 심층 분석 (관련 글)
- 리눅스 네트워킹 내부 (관련 글)
- 네트워크 성능 분석 (관련 글)
Seeing TCP Congestion Control — CUBIC, BBRv3, and a gVisor Netstack Running in Your Browser
- Introduction — Two TCP Stacks Running in a Browser Tab
- The Skeleton — slow start, congestion avoidance, fast retransmit
- What CUBIC Optimizes For
- What BBR Looks At Instead — a Path Model, Not Loss
- Three Symptoms Engineers Actually See
- How to See cwnd and Retransmits on a Real Host
- What the Simulator Cannot Show You
- Closing — Congestion Control Is Still an Estimation Problem
- References
Introduction — Two TCP Stacks Running in a Browser Tab
On July 28, 2026, a link titled Simulating TCP loss and congestion in browser using Go/WASM went up on Hacker News. Open it and you land on a page called ccsim, and what runs there is not a demo animation. The description in the source repository is precise — it stands up two copies of gVisor's userspace TCP/IP stack (gvisor.dev/gvisor/pkg/tcpip), one as the sender and one as the receiver, wedges a token-bucket bottleneck link model between them, and builds that entire event-driven simulator to WebAssembly to run it inside a browser worker.
The motivation is written up on the authors' engineering blog. They use the gVisor netstack to inject packets in their tunneling infrastructure, and probes kept dying on a co-founder's flaky home line. They suspected CUBIC does badly on a lossy link, and trying to confirm that led them to implement BBRv3 on top of netstack themselves and to build a verification harness for it. The simulator is a by-product of that harness.
The reason not to dismiss this as a browser demo is in the repository's verification document. The CUBIC window growth curve fits RFC 9438's cubic function with C=0.410 and R²=0.9999, the loss response function lands within 0.68 to 0.95 times the spec value, and RED's marking curve is compared against the exact ramp probability mass function using a chi-squared test. On top of that, the sample output streams from the native build and the WASM build are byte-for-byte identical. It is a teaching aid and a regression test at the same time.
This post uses the simulator as a hook but puts the real value in the algorithms underneath it. Congestion control has been solving the same problem for more than 30 years, and what we ultimately need to know is which symptoms that sawtooth on the screen turns into on a real server.
The Skeleton — slow start, congestion avoidance, fast retransmit
A TCP sender watches two windows at once. One is the receive window (rwnd) the receiver advertises, "how much the far side can handle"; the other is the congestion window (cwnd), "how much the sender estimates the network can handle." What it can actually put on the wire is the smaller of the two. A congestion control algorithm is, in the end, a set of rules about when and by how much to raise and lower cwnd.
Slow start, despite the name, is exponentially fast. When a connection opens, cwnd is set to the initial window (on modern Linux, 10 MSS per RFC 6928), and every arriving ACK raises cwnd by 1 MSS. Since one ACK releases two new segments, cwnd doubles every RTT. It is a search meant to approach path capacity in close to logarithmic time from a state of knowing nothing about it. This phase continues until ssthresh is reached or a loss is detected.
Congestion avoidance comes next. Now that the sender considers itself near capacity, the growth rate drops to roughly 1 MSS per RTT. On a loss, cwnd is cut multiplicatively. This AIMD (additive increase, multiplicative decrease) structure is the core of what saved the internet from collapse — when several flows share the same bottleneck, AIMD converges toward fairness, whereas multiplicative increase does not.
Fast retransmit is the mechanism for catching a loss far sooner than the retransmission timeout (RTO) would. When a receiver gets an out-of-order segment, it repeatedly ACKs the last contiguously received point. When the sender gets three duplicates of the same ACK, it retransmits that segment immediately instead of waiting for the RTO, enters fast recovery, cuts cwnd roughly in half, and keeps sending. Waiting for the RTO would have burned hundreds of milliseconds at minimum.
One thing has to be flagged here. The paragraphs above are the textbook Reno account, and what Linux actually does for loss detection today is RACK-TLP (RFC 8985). Instead of counting duplicate ACKs, it compares each segment's transmit time against the arrival order confirmed by SACK, and declares a loss when something sent after this segment has already arrived and the reordering window has passed as well. The three-duplicate-ACK rule was weak against reordering and helpless against tail loss (the last segment disappearing, so no duplicate ACKs are generated at all). ccsim runs with RACK/TLP enabled too.
What CUBIC Optimizes For
CUBIC, the default in the Linux, Windows, and Apple stacks, was standardized as RFC 9438 in August 2023 (obsoleting RFC 8312). As the name says, it defines window growth as a cubic function.
W_cubic(t) = C * (t - K)^3 + W_max
t : elapsed time since the last congestion event (seconds)
W_max : window size just before the congestion event
C : scaling constant (default 0.4)
K : cbrt(W_max * (1 - beta) / C), beta = 0.7
The shape of this curve is the entire design intent. Right after a loss it recovers concavely and fast up to near W_max, flattens out almost completely once it reaches W_max and lingers around there probing, and if nothing happens it accelerates convexly above that point to go looking for new capacity. The decrease factor is 0.7 rather than Reno's 0.5, so a single loss also costs less.
As for what it optimized, there are two things.
First, utilization on high-BDP links. Reno's 1-MSS-per-RTT increase is hopelessly slow on a path with large bandwidth and long delay. CUBIC's increase is a function of elapsed time, so it is not tied directly to RTT.
Second, and for exactly that reason, RTT fairness. In the Reno family, a flow with a shorter RTT increases more over the same span of time and takes more bandwidth. Being a function of time, CUBIC has a much smaller bias here.
What CUBIC gave up in exchange is clear. CUBIC's congestion signal is still packet loss and nothing else. It does not look at how full the queue is or how much delay has grown. It pushes up until there is a loss, and cuts when there is one. Both of the symptoms below are derived from that one sentence.
What BBR Looks At Instead — a Path Model, Not Loss
BBR changes the question. Instead of "when does loss happen," it continuously estimates "what are this path's bottleneck bandwidth and minimum round-trip delay," and tries to hold near their product (BDP). The on-screen explanation in ccsim compresses the contrast well — 1 BDP is the optimum, and CUBIC finds that point by crossing over into loss, while BBR estimates the path and stays near it.
This optimum is also something Kleinrock worked out back in 1979. If in-flight is below BDP the link idles; if it is above BDP, the excess all piles up in the bottleneck queue and only adds delay while throughput stays the same. The problem is that you cannot measure bandwidth and minimum delay at the same time — measuring peak bandwidth requires building a queue, and measuring minimum delay requires draining one. So BBR measures the two values in alternation.
The BBRv3 that ccsim implements follows the state machine in draft-ietf-ccwg-bbr (currently revision 06, status Experimental). Carrying over the constants listed in the repository's docs, it looks like this.
- Startup — pacing gain 710/256 (about 2.77), cwnd gain 2.0. Pushes up until the bandwidth estimate stops increasing.
- Drain — pacing gain 88/256 (about 0.34). Drains the queue that Startup built up.
- ProbeBW — a cycle of DOWN (232/256) → CRUISE (1.0) → REFILL (1.0) → UP (1.25). Most of the time is spent here, and only during the UP phase does it push a little harder to check whether bandwidth has grown.
- ProbeRTT — cwnd gain 0.5 for about 200ms. Scheduled at least 5 seconds apart, it drains the queue to refresh the
min_rttfilter.min_rttitself is kept over a 10-second window.
On top of this, what BBRv3 adds relative to v1 is explicit response bounds for loss and ECN. Ceilings such as inflight_hi, inflight_lo, and bw_lo mean that when the path actually returns losses or CE marks, it backs off instead of pushing its model through regardless. This is the answer to the criticism that BBRv1 was excessively aggressive when coexisting with CUBIC.
And the thing that inevitably comes along if you want to run BBR for real is pacing. Opening cwnd and blasting bursts blows up the bottleneck queue instantaneously, so packets have to be spread evenly over the time axis at the estimated bandwidth. On Linux the fq qdisc or in-kernel pacing does this job. ccsim puts it not inside the CC but in a send integration layer, releasing packets through a virtual clock timer in quanta of min(pacing_rate * 1ms, 64KB).
Three Symptoms Engineers Actually See
That was the theory; what follows is the shape these take when they arrive as a ticket. Carrying the scenario measurements from the ccsim repository over as they are makes the three symptoms visible as numbers.
| Scenario | Conditions | Result |
|---|---|---|
| cubic-single | 100Mbps, no loss | 96.5 Mbps, 3 cwnd reductions, 0 RTOs |
| random-loss | 1% random loss | CUBIC 3.0 Mbps vs BBRv3 57 Mbps |
| bufferbloat | deep taildrop buffer, base RTT 30ms | CUBIC steady-state srtt 1,070ms vs BBRv3 32ms |
| ecn-codel | CoDel + ECN | 654 CE marks, 0 drops and retransmits, srtt at most 2.1x of base |
| rate-step | stepped bandwidth change | 24 Mbps delivered immediately, 1.07x the new BDP two cycles after the max-bw filter |
| fairness | CUBIC and BBR coexisting on one 100Mbps link | link 96% utilized, split about 33% vs 67% |
Symptom 1 — throughput collapse on a lossy link. At 1% random loss, CUBIC slumps to 3.0 Mbps. On a 100Mbps link that is 3%. This is not a bug but a direct consequence of a loss-based design, and the Mathis approximation predicts it exactly.
throughput ~ MSS / (RTT * sqrt(p)) * C
MSS 1448B, RTT 40ms, p = 0.01, C ~ 1.22
-> 1.22 * 1448 * 8 / (0.04 * 0.1) ~ 3.5 Mbps
What matters in the formula is that the loss rate enters under a square root. When loss rises tenfold from 0.1% to 1%, throughput falls not to a third but to about one 3.16th. And CUBIC cannot distinguish whether that loss came from congestion or from wireless interference or line quality. That is the classic reason a flow crossing a wireless segment or a long-haul circuit is inexplicably slow, and it is also why BBR gets 57 Mbps — BBR does not shake its model much on loss as long as the delivery rate estimate holds up.
Symptom 2 — bufferbloat. On a bottleneck with a deep buffer, CUBIC's steady-state srtt is 1,070ms. The path's actual round-trip delay is 30ms. A full second of that is queueing time. And here losses barely happen at all — because the buffer is deep, the excess is absorbed as delay rather than as drops. So CUBIC concludes "nothing is wrong" and stabilizes in that state. Under the same conditions BBRv3 holds 32ms. With a base RTT of 30ms, that means the queue is effectively empty.
Symptom 3 — making the buffer bigger makes it worse. This falls out automatically from combining the two paragraphs above. The steady-state queue occupancy of a loss-based CC is decided by the buffer size. Double the buffer and steady-state delay roughly doubles too. The intuition that "we see packet drops, so let's add buffer" only converts drops into delay, and from the point of view of interactive traffic that is a worse trade. Router buffers getting cheap and this mistake spreading across the whole internet is the history of the bufferbloat problem.
The prescription is queue discipline, not buffer size. The ecn-codel row of the table is the picture of it — CoDel watches queueing delay and marks CE preemptively, so there are zero drops and zero retransmits while srtt is pressed down to at most 2.1x of base. It replaced the loss signal with a delay signal, and on Linux fq_codel or cake does this job. On a consumer router this is effectively the only practical solution.
The fairness number in the last row also matters in practice. When CUBIC and BBR share a link, it splits 33% to 67%. The link is 96% full so nothing is wasted, but it is not even either. Even granting that BBRv3 is gentler than v1, this means converting only part of a server fleet to BBR leaves the remaining CUBIC flows worse off. Better to switch by domain than in fragments.
How to See cwnd and Retransmits on a Real Host
Every value the simulator draws so nicely can also be seen on a Linux host. No instrumentation code required.
# Current default algorithm and the list of available ones
sysctl net.ipv4.tcp_congestion_control
sysctl net.ipv4.tcp_available_congestion_control
# To use BBR you need the module and a pacing qdisc together
sudo modprobe tcp_bbr
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
tc qdisc show dev eth0 # check whether it is fq or fq_codel
# Per-socket state: cwnd, ssthresh, rtt, retransmits, delivery rate, pacing rate all show up here
ss -tin state established
# cubic wscale:7,7 rto:236 rtt:35.6/2.1 mss:1448 cwnd:73 ssthresh:52
# bytes_sent:... retrans:0/14 delivery_rate:76.4Mbps busy:... pacing_rate:...
# Only a specific destination
ss -tin dst 203.0.113.10
# Host-wide retransmit counters (deltas only)
nstat -az | grep -Ei 'TcpRetransSegs|TcpExtTCPLostRetransmit|TcpExtTCPTimeouts|TcpExtTCPFastRetrans'
The three most practically important values in ss -tin output are these.
cwnd— in packets. Dividecwnd * mssbyrttand you get the ceiling throughput at that instant. Compare it againstdelivery_rateand you can separate the application failing to fill the pipe (app-limited) from the network holding it back.retrans:X/Y— the first is currently outstanding retransmits, the second is cumulative. What percentage the second number is ofbytes_sentis the loss ratepfrom the previous section.rtt:A/B— A is the smoothed RTT, B is the variance (mdev). If A is far above the path's minimum RTT while retransmits are near zero, that is the signature of bufferbloat.
If you want to sample periodically and watch the cwnd sawtooth with your own eyes, this much is enough.
# Pull out only cwnd/rtt/retrans every 200ms and keep it as a time series
while :; do
date +%s.%N | tr -d '\n'
ss -tin dst 203.0.113.10 | tr '\n' ' ' \
| grep -oE 'cwnd:[0-9]+|rtt:[0-9.]+|retrans:[0-9]+/[0-9]+' | tr '\n' ' '
echo
sleep 0.2
done
To go down to the kernel event level, the tracepoints are already in place.
# Watch congestion state transitions and retransmits as events
sudo bpftrace -e '
tracepoint:tcp:tcp_retransmit_skb { @retrans[comm] = count(); }
tracepoint:tcp:tcp_cong_state_set { @state[args->cong_state] = count(); }'
# Building a reproduction: 1% loss + 20ms delay + shallow queue
sudo tc qdisc replace dev eth0 root netem loss 1% delay 20ms limit 100
# Reproducing bufferbloat: make the queue very deep
sudo tc qdisc replace dev eth0 root netem delay 20ms limit 10000
# Confirming the prescription: swap in AQM
sudo tc qdisc replace dev eth0 root fq_codel
Set up loss with netem and send the same file over CUBIC and over BBR in turn, and within 30 seconds you will feel what kind of difference the 3.0 Mbps versus 57 Mbps in the previous section's table is. The overall internal structure of the Linux network stack is written up separately in Linux networking internals, and TCP's own behavior in TCP deep dive.
What the Simulator Cannot Show You
As a teaching aid, ccsim's strengths are clear. It runs a real protocol implementation, so the SACK scoreboard, RACK reordering decisions, and ECN echo are the real thing rather than approximations, and it is deterministic, so the same seed produces the same result. Move a slider and the parameter changes while the simulation is still running, so how differently the two algorithms react when bandwidth suddenly drops is visible on a single screen.
At the same time the boundaries of the simulation are just as clear.
- There are one or two flows. A real bottleneck has thousands of flows, and most of them are short enough that they never even leave slow start. Steady-state behavior accounts for a smaller share of internet traffic than you would think.
- There is one bottleneck and it is fixed. On a real path the bottleneck moves, and the capacity of a wireless segment fluctuates second by second.
- There are no middleboxes. Gear that strips ECN bits, firewalls that break SACK, and proxies that terminate TCP on your behalf are all common on the real internet. A large share of the cases where BBR is turned on but the expected effect never appears live right here.
- There is no CPU or interrupt handling. GRO, TSO, NIC queues, and which core handles softirq shift the delay distribution substantially in reality.
So what you should take from the simulator is not "BBR gets N Mbps in our environment" but which signals this algorithm reacts to and which ones it does not. With that sense in hand, what you can read out of a single line of ss -tin output changes noticeably.
Closing — Congestion Control Is Still an Estimation Problem
To summarize.
- cwnd is the sender's estimate of network capacity. The difference between algorithms is a difference in what they take as evidence for updating that estimate.
- CUBIC uses loss and only loss as evidence. So it mistakes wireless loss for congestion, and in front of a deep buffer it never receives the evidence and stabilizes with the queue full.
- BBR builds a path model out of delivery rate and minimum RTT and aims near BDP. BBRv3 adds loss and ECN response bounds on top of that and improves coexistence, but the split when sharing with CUBIC is still not even.
- Growing the buffer is a trade that converts drops into delay. The answer is not queue size but AQM such as
fq_codel. - All of this can be reproduced and observed on your own host with three tools:
ss -tin,nstat, andtc netem.
When you get a "it's slow" ticket after not having looked at congestion control in a long time, the instinct is usually to suspect bandwidth. In practice it is more often that cwnd is pinned by the loss rate, or that RTT is inflated by a queue. In both cases adding bandwidth improves nothing.
References
- Simulating TCP loss and congestion in browser using Go/WASM — Hacker News (item 49088098, 2026-07-28)
- ccsim — CC Lab demo
- apoxy-dev/ccsim — source, verification document, scenario measurements
- BBRv3 for gVisor's netstack — the Apoxy engineering blog
- RFC 9438 — CUBIC for Fast and Long-Distance Networks (2023-08, Standards Track)
- draft-ietf-ccwg-bbr — BBR Congestion Control (IETF, Experimental)
- RFC 8985 — The RACK-TLP Loss Detection Algorithm for TCP
- RFC 6928 — Increasing TCP's Initial Window
- gVisor netstack — pkg/tcpip
- TCP deep dive (related post)
- Linux networking internals (related post)
- Network performance analysis (related post)