DEV Community

Cover image for I Set TCP_DEFER_ACCEPT to 10; 15 Seconds Later, an Empty Connection
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

I Set TCP_DEFER_ACCEPT to 10; 15 Seconds Later, an Empty Connection

I wanted to spare a listening socket's accept() call from dealing with empty connections. Linux has a switch for exactly that: TCP_DEFER_ACCEPT. The promise is simple — a client that has finished the handshake but has not yet sent a single byte will not reach you; I will wait until data arrives.

I set the switch to 10 seconds. Then, out of habit, I read back what I had written: getsockopt() said 15.

If the number you write and the number you read are not the same, there is a third number too: how long the connection actually waits. That one got a stopwatch too. At the median of three runs, accept() returned after 15.4 seconds — and what I held was a connection with not one byte of data. The deferral window had expired, and the kernel had not broken its promise; nobody had simply told me that the promise comes with an expiry date.

Two machines, the same oddity

So as not to draw a general rule from one machine's quirk, I ran the measurement in two places.

Machine A: the linuxkit virtual machine of Docker Desktop on my Mac — aarch64, kernel 6.10.14, an ubuntu:24.04 container inside it, Python 3.12.3, iproute2 6.1.0. Defaults: tcp_synack_retries=5, tcp_syncookies=1, somaxconn=4096.

Machine B: VPS3, the server that hosts this blog — x86_64, kernel 6.8.0-147-generic, Python 3.12.3. Different defaults: tcp_synack_retries=2, somaxconn=65535.

On both machines the measurement ran over 127.0.0.1 with a single listening socket. The measuring code is three pieces: a listener, a client, and a loop that samples state from the outside with ss.

What I write is not seconds, it is a retransmission count

The first measurement is the simplest: open a socket, write a number into TCP_DEFER_ACCEPT, read it straight back. I tried 26 values. Eight came back unchanged, eighteen changed — and both machines produced exactly the same table.

What I wrote What getsockopt() answered
1 1
2 3
3 3
4 7
7 7
10 15
15 15
30 31
60 63
128 247
300 367
3600 3607

Of the 26 values I tried, the eight that came back unchanged are no coincidence: 0, 1, 3, 7, 15, 31, 63, 127. One less than a power of two. After 127 the series breaks and continues in steps of 120 — 3607 in the table also maps to itself, so it is simply a later stop on the same series.

The reason is written in two small functions in net/ipv4/tcp.c. On the setsockopt() path the kernel converts the seconds you give into a retransmission count with secs_to_retrans(); on the getsockopt() path it turns that count back into seconds with retrans_to_secs(). The conversion's first step is TCP_TIMEOUT_INIT, that is 1 second; it doubles each round and tops out at TCP_RTO_MAX_SEC, which is 120 seconds. The resulting series: 1, 1+2=3, 1+2+4=7, 15, 31, 63, 127, then +120, +120…

I downloaded the source of these two functions from the 6.8, 6.10 and 7.3 tags and compared them: byte for byte identical. The two machines producing the same table is therefore not a surprise but the expected result.

What the socket actually stores is a single u8 named rskq_defer_accept. Seconds do not fit in there; what fits is "defer for this many retransmission rounds". The ceiling used by the conversion is a compile-time constant too: TCP_RTO_MAX_SEC. Yet 7.3 has a separate option, TCP_RTO_MAX_MS, that changes that upper bound per socket, and the timer that paces the rounds reads it. The two know nothing about each other: on a listener whose bound is lowered, getsockopt() still says 15 while the wait is not 15 seconds long. That one I read in the code — the kernels on my measuring machines do not know the option yet. When you write 10, the kernel asks this: what is the smallest round count whose cumulative duration does not fall below 10 seconds? The answer is 4, and four rounds take 15 seconds. The code does exactly that: a while (seconds > period) loop adds rounds until the accumulated duration passes the seconds you asked for.

This behavior is not undocumented either. The tcp(7) manual page says it in one sentence: the value is rounded up to the next larger TCP retransmit duration. The sentence sits there, but I have never seen anyone translate it as "write 10, get 15" — and I would have missed it too, had I not read back what I wrote.

The paragraph right after that manual entry holds a second surprise: the very same socket option does something entirely different on a client socket. There, a nonzero value delays the final ACK of the handshake until the socket has data to send; the ACK then rides on the first data segment, or waits for a delayed-ACK timeout. One name, two behaviors at the two ends — it is easy to read the listener half and assume the client half matches.

The real lesson here is about my own practice: reading a socket option back after setting it is thirty seconds of work. Every time I skip it, I am trusting a guess — the post about the neighbouring container that shrank my pipe buffer eightfold came from the same place: F_SETPIPE_SZ handed back a rounded value rather than the one I asked for, and code that never reads the return value does not know its own budget.

A silent client: 15 seconds later, empty

The second measurement tests the promise itself. I open the listener with a value D, a client calls connect() and sends nothing. When does accept() return, and what do I hold when it does?

On machine A, with the tcp_synack_retries=5 default — each row is three separate runs, so that no number rests on a single measurement:

D getsockopt() accept() returned Data ready
1 1 s 1.013 / 1.025 / 1.029 s 0 bytes
2 3 s 3.066 / 3.091 / 3.096 s 0 bytes
10 15 s 15.186 / 15.377 / 15.505 s 0 bytes

On machine B, on a different architecture and a different kernel: 1.022 seconds for D=1, 15.587 seconds for D=10. Again 0 bytes.

accept() really is delayed, and the delay lands on the number getsockopt() reported. The point is what happens at the end of the window: the connection is delivered anyway. Empty.

Looking with ss, the connection sits in SYN-RECV throughout the window and moves to ESTAB when it expires. (I did not convert the sampler loop's counter index into seconds: on a busy server ss itself slows down and the index-to-time mapping drifts. ss shows the state transition; the duration comes from accept()'s timestamp.)

The client side, meanwhile, believes it is connected; its connect() returned in under a millisecond. So there is no broken connection here, just fifteen seconds in which the two ends answer the question "are we connected?" differently.

What the kernel does during that window

nstat showed two counters: TcpExtTCPDeferAcceptDrop went up by one, and TcpExtTCPSynRetrans went up by one. Both describe exactly how the mechanism works.

The ACK that completes the handshake is a bare ACK, carrying no data. tcp_check_req() in tcp_minisocks.c sees that packet and drops it if the deferral rounds are not exhausted — its comment says precisely that: while TCP_DEFER_ACCEPT is active, the bare ACK is dropped. While dropping it, the kernel leaves a mark on the request saying "this client did send its ACK" (acked = 1) and bumps the TCPDeferAcceptDrop counter. The request thus stays in SYN-RECV and never enters the accept queue.

The rest is the job of syn_ack_recalc() in inet_connection_sock.c. On every timer round the function asks two questions: should this request be deleted, should the SYN-ACK be resent? With deferral on, the resend decision goes like this: if the client has not ACKed yet, resend; if it has, stay quiet until the deferral's last round. The comment in the source states the intent plainly — to give data or an ACK one last chance.

On that last round the kernel resends the SYN-ACK. The client, receiving a SYN-ACK for a connection it considers long established, follows the rule and sends its ACK again. But this time the deferral counter is exhausted: that same bare ACK is no longer dropped, a child socket is created, the connection moves to ESTAB and accept() returns. You hold zero bytes of data.

Diagram

So TCP_DEFER_ACCEPT is not a filter, it is a delay. It does not weed out the connection that brings no data; it postpones its delivery, and then delivers it anyway.

When the promise is kept

To be fair, the other direction went on the bench as well. Same listener, D=1, but this time the client talks.

When the client sent 16 bytes right after connect(), accept() returned in 0.002 seconds with the data ready. When it waited half a second before sending, accept() returned in 0.504 seconds, again with data. In both cases TCPDeferAcceptDrop went up by one — so the handshake ACK was dropped either way; what created the connection was the first data packet.

That detail is the nice side of the mechanism: with deferral on, the server side never sees a connection that has done nothing but shake hands. The connection is born the moment the first data arrives. For protocols whose clients speak immediately, this is a free win — one wake-up per accept(), one epoll round, one wasted read attempt, all gone.

And that is exactly where it is free: where the client speaks immediately.

Protocols where the server speaks first

There is a common sentence about this trap: "don't use deferred for SSH, the server speaks first." Going to the source, I found the sentence is wrong. RFC 4253 says that when the connection is established, both sides must send an identification string. Rather than stop at the document, I pointed my Mac's OpenSSH 10.3p1 client at a listener that never speaks; the client sent its SSH-2.0-OpenSSH_10.3 line the moment the handshake finished. So SSH is in the "client speaks immediately" class as far as deferral goes.

SMTP is not. RFC 5321 says a session begins when the client opens a connection and the server responds with an opening message; the client only sends EHLO after receiving that greeting. Combine that order with deferral: the server process waits in accept(), the client waits for the 220 line, nobody speaks. The "silent client" in my lab is exactly this SMTP client — and the measurement shows that in this case the connection sits dead for 1 second with D=1 and 15.4 seconds with D=10, then gets delivered to the server empty.

It is sturdier to frame the rule as a question rather than a list of protocol names: who sends the first byte on this port? If the answer is "the client", deferral helps you. If it is "the server", you add the entire deferral window to every connection as latency. If you do not know the answer, watching one connection with tcpdump takes five minutes; guessing is not free, as that common sentence about SSH shows.

The second gate: tcp_synack_retries

Reading the source raised a suspicion. In syn_ack_recalc() the condition for deleting a request depends on two things: the tcp_synack_retries threshold and the deferral counter. If the client has sent its ACK — as it has in my scenario — the request is not deleted until both are exceeded. (If the client never ACKs at all, the !acked branch kicks in and tcp_synack_retries suffices on its own; that path is not this post's subject.) So does trimming tcp_synack_retries also shorten the deferral?

So I ran the same test on machine A with tcp_synack_retries=1: for D=2, accept() still returned at 3.077 seconds. So, no. On machine B the setting is already 2, below the default; the durations there landed in the same range as machine A's (1.022 seconds for D=1, 15.587 seconds for D=10).

The logic settled afterwards: tcp_synack_retries governs the deletion of the request, not its delivery. Delivery happens via the ACK the client sends in reply to the SYN-ACK of the last round; when that ACK arrives the request is not deleted, it is promoted. The two settings do not interfere. The post about who inherits a closing socket's queue had a similar distinction: how full the queue is and what happens to the things in it are two different mechanisms.

Under load, deferral turns itself off

The last measurement was the one that bothered me most. I sent 200 silent clients all at once — non-blocking sockets, all 200 SYNs on the wire within 3 milliseconds — at a listener whose listen() backlog was 10. Three runs, machine A, D=1, a 20-second observation window:

Observation Run 1 Run 2 Run 3
TCPDeferAcceptDrop — passed through the deferral gate +10 +10 +10
SyncookiesSent / SyncookiesRecv — never saw the gate +190 +190 +190
TCPReqQFullDoCookies +295 +306 +326
ListenOverflows +205 +230 +271
Empty connections accept()ed within 20 s 100 86 65

The first two rows are identical to the digit across all three runs, and they add up to exactly 200: 10 of the connections went through the deferral gate, 190 never saw it.

The 10 is no coincidence. Deferred connections live in the request queue, and that queue's ceiling is the listen() backlog. Because the comparison in the code uses >, the real ceiling is one more than the backlog; all three runs came out at 10.

Once the queue fills, the kernel falls back to SYN cookies. The SYN cookie path does not use a pending request socket: once the cookie in the incoming ACK validates, syncookies.c builds a request structure on the spot, turns it into a child socket immediately and puts it straight into the accept queue. The deferral gate lives inside tcp_check_req(), so it never runs on that path — those 190 connections went to accept() directly, and empty.

The bottom three rows depend on timing and move from run to run. TCPReqQFullDoCookies exceeding 200 is not a contradiction: that counter is bumped inside tcp_syn_flood_action(), before the accept-queue fullness check. A SYN that arrives while the queue is full is dropped after the counter has already moved (ListenOverflows), and the client resends its SYN — so one connection can bump the counter several times. That is also why the number of empty connections accept()ed in 20 seconds wanders between 65 and 100: the accept queue seats ten, and everyone else keeps retrying for a place in line.

The practical meaning: deferral switches itself off exactly when you would need it — under load. It is not a defense layer against a malicious load; SYN queue pressure still has to be managed with the tools I described in the TCP backlog and SYN flood runbook.

A second path where the deferral gate never runs

The bypass under load is not the only exception. A few lines before the deferral check in tcp_check_req() there is an early exit: if the connection was established with TCP Fast Open, the function returns right there. With TFO the child socket is already born at SYN time, so there is no pending request to hold back — meaning that if you turn on fastopen=3 and deferred on the same listener, deferral never runs for connections that arrive via TFO. I read that in the code rather than measuring it; the post where I put TCP Fast Open on the bench shows that TFO's own conditions are fiddly enough as it is.

One note on scope as well: all of my measurements are over IPv4, on 127.0.0.1. The two files where the deferral logic lives (tcp_minisocks.c, inet_connection_sock.c) are address-family agnostic, so I would not expect IPv6 to behave differently — but I did not measure it, and you should know that.

What nginx writes, and what it does

nginx's listen ... deferred parameter gets one line in the documentation: on Linux it uses the TCP_DEFER_ACCEPT socket option. The docs do not say which value it uses; the source does. In src/core/ngx_connection.c the value is hardcoded to 1. The comment next to it explains why — there is no way to learn how long a connection waited in the queue, and besides, a connection may bypass the deferred queue entirely if SYN cookies were used.

What nginx admits in its own source comment showed up as numbers across three runs here. It is a good example of why also looking at the source while reading the docs is such a cheap habit.

Out of curiosity I looked at HAProxy too: in src/proto_tcp.c the defer-accept option sets accept_delay = 1, with a "defer accept by up to one second" comment beside it. Two independent proxies landed on the same number. So "the deferral window is one second" is not an nginx-specific preference; it is the industry's shared call.

What you get when you write deferred, in the language of these measurements: a one-second window. Reasonable for HTTP, whose clients speak immediately. If the window expires the connection arrives anyway, empty — so deferred does not say "empty connections never arrive", it says "it waits about a second".

I also checked what I use on my own server: there is not a single listen line containing deferred in VPS3's nginx configuration. And I will not be adding one after this measurement — for the nginx in front of this blog, one accept() wake-up is cheaper than the complexity a one-second window brings.

A decision frame

The questions I ask when weighing this switch, in order:

  1. Who sends the first byte on this port? If it is not the client, stop here; deferral adds dead latency to every connection.
  2. What will I gain? The gain is the accept() + epoll + first read attempt spent on connections that bring no data. Measure that first: if most of your listener's wake-ups do bring data, there is nothing to gain.
  3. Did I read back the value I wrote? With getsockopt(). Trust the 1-3-7-15-31 series, not the number you wrote.
  4. Do I accept what happens when the window expires? The empty connection still arrives. If your application was written on a "data guaranteed" assumption, deferral does not save that assumption.
  5. What happens under load? The request queue is bounded by the listen() backlog, and once it fills, SYN cookies bypass the deferral logic. Deferral is not a security measure.
  6. Which counter tells me the truth in monitoring? nstat -az TcpExtTCPDeferAcceptDrop. If it is not rising, deferral never engages; if it rises alongside SyncookiesSent, it is being bypassed under load.

Conclusion

TCP_DEFER_ACCEPT is not a bad option; it just speaks its own language in two places rather than ours. The first is its unit: we write seconds, the kernel reads retransmission rounds — and because it has no stopwatch of its own, it rounds up to the nearest round boundary. The second is its duration: at the end of "wait until data arrives" there is an "enough already", and at that point it delivers the connection empty.

The lesson I am left with goes beyond the unit. A kernel switch's documentation, its code and its behavior do not tell three different stories — but they tell the story at three different levels of detail. The docs said "rounded up", the code said which series it rounds to, and the measurement showed that this means 15.4 seconds when you write 10. Reading all three together took thirty minutes. How long chasing a mysterious fifteen-second delay in production would have taken is harder to guess.

Official Sources

Top comments (0)