Skip to content

Hibernate connection processes after 15 seconds idle - #743

Merged
mtrudel merged 1 commit into
mtrudel:mainfrom
mpettibone:hibernate
Nov 4, 2026
Merged

mtrudel merged 1 commit into
mtrudel:mainfrom
mpettibone:hibernate

Conversation

@mpettibone

@mpettibone mpettibone commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

(Claude helped with test cases)

An idle connection costs more memory than it needs to. When a keepalive HTTP/1 connection finishes a request, its process keeps whatever heap that request grew: the parsed headers, the conn, the response, all of it garbage now, but it stays until the next collection, and Bandit only forces one every five requests. So a connection that served one request and then went quiet, which is most browser connections most of the time, holds that request's leftovers for as long as it stays open. HTTP/2 connections do the same, and a WebSocket keeps the remains of its upgrade. How much depends on what the plug did: 9 KB for a plug that just sends a response, 21 KB once it does as much as parsing a query string, and 588 KB after serving a templated 60 KB page, in each case for a connection that is doing nothing.

The VM has a fix for exactly this: hibernate_after. A GenServer started with it hibernates once it has had no message for that long, which runs a full collection, compacts the heap and parks the process; the next message wakes it as usual. This PR sets genserver_options: [hibernate_after: 15_000] in the options Bandit passes to Thousand Island, the same way it already defaults the port, transport and handler. Anything the user puts in genserver_options is kept, so hibernate_after: :infinity turns it off and any other value changes the delay. Nothing else changes: a connection in use never goes 15 seconds without a message, so the only connections that hibernate are the ones sitting idle.

This belongs in Bandit rather than Thousand Island, I think. Thousand Island hands genserver_options to the handler untouched and stays out of protocol decisions, and what "idle" means and how much a request leaves behind are properties of the protocol. Bandit already owns this kind of per-connection housekeeping (gc_every_n_keepalive_requests, clear_process_dict, the collection it runs at WebSocket upgrade); this is the piece that covers the time between requests.

Numbers

Measured on main at 4812459 in a 2-core VM at 2.1 GHz, OTP 25.3, Elixir 1.17.3. Memory is Process.info/2 of the connection process, read while the connection is idle; the CPU figures come from :erlang.statistics/1 on a single scheduler with the client in the same VM, so they are upper bounds.

Memory per idle connection, before and after hibernating:

Idle connection Today Hibernated
HTTP/1 keepalive, plug sends a short body 8.6 KB 2.1 KB
HTTP/1 keepalive, plug also parses the query string 21.1 KB 2.1 KB
HTTP/1 keepalive, plug renders a 60 KB page 587.7 KB 2.1 KB
HTTP/2, one request served 21.1 KB 3.3 KB
WebSocket, after the handshake 8.6 KB 2.7 KB

For scale: 3,000 idle connections, a thousand each of the first HTTP/1 case, HTTP/2 and WebSocket, hold 49.6 MB today and 7.9 MB hibernated. With the templated page it is 587 MB per thousand idle connections against 2 MB.

What hibernating costs, measured over 2,000 HTTP/1 connections:

Cost Measured
Hibernating a connection 8 to 10 us CPU, once per idle period
First request after waking, CPU about 8 us more (55 to 64 us)
First request after waking, latency p50 80 to 100 us either way, p99 same
Throughput under keepalive load 22,089 vs 22,279 req/s, within noise

The last row is the important one for anyone worried about the hot path: a connection under load never hibernates, because it never sits idle for 15 seconds. The cost lands only on connections that were idle, and it is one collection the connection would have had to pay eventually anyway, plus a few microseconds to regrow the heap. For a WebSocket under Phoenix's 30 second heartbeat, which wakes and re-hibernates every cycle, that comes to about 20 us per connection per 30 seconds: roughly 7% of one core for 100,000 connections, against the memory in the table.

One side effect that turned out to be a benefit: a connection that was woken serves its next request from a compact heap, so after that request it sits at 8.6 KB instead of growing to 26 KB as it did on main. Hibernation resets the growth that keepalive connections otherwise accumulate between forced collections.

Why 15 seconds

Long enough that no connection in use ever hibernates between requests; short enough that the common idle cases do: a browser holding its keepalive connections open for minutes after a page load, a WebSocket between heartbeats, an HTTP/2 connection kept around by a client that has gone quiet. It's also the figure Phoenix uses for the same purpose on channel processes, so a LiveView deployment ends up with both ends of a socket behaving the same way.

Tests

test/bandit/hibernation_test.exs is new. Four quick tests use a 100 ms hibernate_after passed through thousand_island_options to check that an idle connection process actually hibernates and then carries on serving (the next keepalive request, the next HTTP/2 stream, the next WebSocket frame), and that hibernate_after: :infinity is respected over Bandit's default, which is the put_new behaviour. A fifth test checks the 15 second default itself by waiting for it; it's tagged :slow like the other long tests in the suite. Suite, format, credo and dialyzer are clean.

An idle connection costs more memory than it needs to. When a keepalive HTTP/1 connection finishes a request, its process keeps whatever heap that request grew: the parsed headers, the conn, the response, all of it garbage now, but it stays until the next collection, and Bandit only forces one every five requests. So a connection that served one request and then went quiet, which is most browser connections most of the time, holds that request's leftovers for as long as it stays open. HTTP/2 connections do the same, and a WebSocket keeps the remains of its upgrade. How much depends on what the plug did: 9 KB for a plug that just sends a response, 21 KB once it does as much as parsing a query string, and 588 KB after serving a templated 60 KB page, in each case for a connection that is doing nothing.

The VM has a fix for exactly this: `hibernate_after`. A GenServer started with it hibernates once it has had no message for that long, which runs a full collection, compacts the heap and parks the process; the next message wakes it as usual. This PR sets `genserver_options: [hibernate_after: 15_000]` in the options Bandit passes to Thousand Island, the same way it already defaults the port, transport and handler. Anything the user puts in `genserver_options` is kept, so `hibernate_after: :infinity` turns it off and any other value changes the delay. Nothing else changes: a connection in use never goes 15 seconds without a message, so the only connections that hibernate are the ones sitting idle.

This belongs in Bandit rather than Thousand Island, I think. Thousand Island hands `genserver_options` to the handler untouched and stays out of protocol decisions, and what "idle" means and how much a request leaves behind are properties of the protocol. Bandit already owns this kind of per-connection housekeeping (`gc_every_n_keepalive_requests`, `clear_process_dict`, the collection it runs at WebSocket upgrade); this is the piece that covers the time between requests.

## Numbers

Measured on `main` at `4812459` in a 2-core VM at 2.1 GHz, OTP 25.3, Elixir 1.17.3. Memory is `Process.info/2` of the connection process, read while the connection is idle; the CPU figures come from `:erlang.statistics/1` on a single scheduler with the client in the same VM, so they are upper bounds.

Memory per idle connection, before and after hibernating:

| Idle connection | Today | Hibernated |
|---|---|---|
| HTTP/1 keepalive, plug sends a short body | 8.6 KB | 2.1 KB |
| HTTP/1 keepalive, plug also parses the query string | 21.1 KB | 2.1 KB |
| HTTP/1 keepalive, plug renders a 60 KB page | 587.7 KB | 2.1 KB |
| HTTP/2, one request served | 21.1 KB | 3.3 KB |
| WebSocket, after the handshake | 8.6 KB | 2.7 KB |

For scale: 3,000 idle connections, a thousand each of the first HTTP/1 case, HTTP/2 and WebSocket, hold 49.6 MB today and 7.9 MB hibernated. With the templated page it is 587 MB per thousand idle connections against 2 MB.

What hibernating costs, measured over 2,000 HTTP/1 connections:

| Cost | Measured |
|---|---|
| Hibernating a connection | 8 to 10 us CPU, once per idle period |
| First request after waking, CPU | about 8 us more (55 to 64 us) |
| First request after waking, latency | p50 80 to 100 us either way, p99 same |
| Throughput under keepalive load | 22,089 vs 22,279 req/s, within noise |

The last row is the important one for anyone worried about the hot path: a connection under load never hibernates, because it never sits idle for 15 seconds. The cost lands only on connections that were idle, and it is one collection the connection would have had to pay eventually anyway, plus a few microseconds to regrow the heap. For a WebSocket under Phoenix's 30 second heartbeat, which wakes and re-hibernates every cycle, that comes to about 20 us per connection per 30 seconds: roughly 7% of one core for 100,000 connections, against the memory in the table.

One side effect that turned out to be a benefit: a connection that was woken serves its next request from a compact heap, so after that request it sits at 8.6 KB instead of growing to 26 KB as it did on `main`. Hibernation resets the growth that keepalive connections otherwise accumulate between forced collections.

## Why 15 seconds

Long enough that no connection in use ever hibernates between requests; short enough that the common idle cases do: a browser holding its keepalive connections open for minutes after a page load, a WebSocket between heartbeats, an HTTP/2 connection kept around by a client that has gone quiet. It's also the figure Phoenix uses for the same purpose on channel processes, so a LiveView deployment ends up with both ends of a socket behaving the same way.

## Tests

`test/bandit/hibernation_test.exs` is new. Four quick tests use a 100 ms `hibernate_after` passed through `thousand_island_options` to check that an idle connection process actually hibernates and then carries on serving (the next keepalive request, the next HTTP/2 stream, the next WebSocket frame), and that `hibernate_after: :infinity` is respected over Bandit's default, which is the `put_new` behaviour. A fifth test checks the 15 second default itself by waiting for it; it's tagged `:slow` like the other long tests in the suite. Suite, format, credo and dialyzer are clean.
@mtrudel
mtrudel merged commit e8ad2b9 into mtrudel:main Nov 4, 2026
26 of 29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants