The request ended, the connection didn't
Sooner or later an application grows a page that has to show something on its own: a
notification, the progress of a long job, somebody else’s cursor in a shared document.
Rails has three answers to that — ActionController::Live, SSE through an ordinary
response body, and ActionCable — and every one of the three gets described with the word
“streaming.”
One word. Underneath it, three mechanisms that work in opposite directions. The first costs two threads per connection. The second costs one. The third costs none, because it takes the socket away from the server, and with it half of what the server was doing for you.
Worth knowing not because you get to choose — the choice is usually made for you — but
because the third one is running in your application right now if actioncable is in
your Gemfile. And in that case the log line about a request that finished in three
milliseconds describes a connection that will live another four hours.
The mechanism that does this is called rack.hijack.
Everything below is written against Rack 3. Rails has been able to run on it since
7.1, but it still doesn’t require it: actionpack 8.1 declares rack >= 2.2.4, so an
application that rode up to Rails 8 from 7.0 is probably still on Rack 2 and nothing is
going to mention it — check Gemfile.lock. Rack 2 differs, and it differs in precisely
the places that have the most blog posts written about them, so the differences get
their own section and are flagged along the way.
The contract you’re walking out of
The Rack contract fits in one line: an application takes env and returns three values
— a status, headers, a body. The server is what writes them to the socket.
def call(env)
[200, { "content-type" => "text/plain" }, ["hi"]]
end
Everything else grows out of the socket belonging to the server. The server counts concurrent requests and sizes its thread pool accordingly. It sets timeouts. Keep-alive is its call. On deploy it waits for the requests already in flight, and only then dies. The application knows none of this, and that’s the right division of labor.
rack.hijack is the door the application walks out through.
The idea is simple.
A hijack isn’t “a way to do streaming.” It’s a trade: the application takes the socket, and with it all the bookkeeping the server was doing on its behalf. Sometimes the trade is a good one.
Full hijack
env["rack.hijack"] is a callable. Call it and you get the socket.
def call(env)
return [501, {}, []] unless env["rack.hijack"]
io = env["rack.hijack"].call
io.write("HTTP/1.1 101 Switching Protocols\r\n")
io.write("upgrade: websocket\r\n")
io.write("connection: Upgrade\r\n")
io.write("sec-websocket-accept: #{accept}\r\n")
io.write("\r\n")
[-1, {}, []]
end
That first line is already a version difference. In Rack 3, support for a full hijack is
announced by the key simply being there; there’s no separate flag. Rack 2 asked
env["rack.hijack?"], which answered for both kinds of hijack at once. That’s exactly
where the older examples you’ll find begin.
Now for what isn’t here. Nobody writes the status line for you: you write it yourself,
with the \r\n in the right places. Content-Length is yours too, Transfer-Encoding
is yours, and HTTP/1 semantics are yours in full. The server never looks at this socket
again.
[-1, {}, []] means “I answered it myself,” and it’s a convention among servers rather
than part of the spec: Rack::Lint rejects that response, since a status has to be an
integer of at least 100. Formally the server ignores your response entirely after a full
hijack, so you could return anything — but people write -1, because Puma, Unicorn, and
Thin all understand it. Puma takes it literally and checks it strictly: unless headers.empty? and res_body == [], or it raises. Not that it usually gets that far —
return :async if client.hijacked comes first.
One more thing people trip over: the spec promises an IO, and you don’t always get
one. Puma puts its own Client in env["rack.hijack"], and under TLS calling it hands
back a Puma::MiniSSL::Socket — you can write to it, but IO.select won’t take it
without to_io. ActionCable never notices only because nio4r calls to_io for it.
HTTP/1 and HTTP/1 only, in both versions — though only one of them says so out loud. Rack 3’s spec has the line; Rack 2’s doesn’t, because HTTP/2 was never in scope for it. The reason isn’t laziness: in HTTP/2 several requests live inside a single TCP connection at once, so “take the socket” would mean taking other people’s requests along with yours. There’s no socket that’s yours alone to take.
Partial hijack
The second kind looks similar and works differently. The server writes the status and headers itself, then hands you the socket for the body.
def call(env)
return [501, {}, []] unless env["rack.hijack?"]
body = proc do |stream|
10.times do |i|
stream.write("data: #{i}\n\n")
sleep 1
end
ensure
stream.close
end
[200, { "content-type" => "text/event-stream", "rack.hijack" => body }, []]
end
Here rack.hijack is a response header, not an env key — two different mechanisms
sharing one name. The server is required to ignore the response body; the spec
recommends an empty array.
The rack.hijack? check on the first line isn’t decoration: with no flag present, the
rack.hijack response header must not be there either. It’s the only place in Rack 3
where that flag means anything at all.
And that is the only thing the mechanism is still there for. In Rack 3 the same thing is
written without any hijack: the body itself can be a callable, and the server calls it
with the same stream.
[200, { "content-type" => "text/event-stream" }, body]
The same proc, no special keys. Inside Puma it’s literally the same branch:
response_hijack = resp_info[:response_hijack] || res_body. The spec says so outright:
partial hijack is functionally equivalent to a streaming body, kept for backward
compatibility with older versions of Rack.
What’s Rack 2 here and what’s Rack 3
There aren’t many differences, and nearly every one of them explains why the example you found in a search doesn’t work.
| Rack 2 | Rack 3 | |
|---|---|---|
env["rack.hijack?"] |
“the server supports hijacking” — both kinds | partial only |
env["rack.hijack"] |
call it for a full hijack | same, and it’s also the flag |
env["rack.hijack_io"] |
the server put the socket there | dropped from the spec |
rack.hijack response header |
the only way to write the body yourself | backward compatibility |
a proc as the body |
no | the standard interface |
env["rack.protocol"] |
no | added in 3.1 |
| header keys | any case; Content-Type by convention |
lowercase only |
Two of those rows deserve an explanation.
rack.hijack_io wasn’t dropped for being useless. The server put the socket into
env after middleware had already had the chance to env.dup it — so there was really
nowhere to put it: the copy the value lands in isn’t the one the application reads. It
was dropped from the spec, though, not from the servers. Puma still sets it, ActionCable
still reads it as a fallback, and Puma::CommonLogger still uses it to recognize a
hijacked request. So code written against Rack 2 won’t break here — it just leans on
something nobody promises it anymore.
Lowercase headers are a Rack 3 thing too. The content-type in the examples above
is written that way for a reason: Rack 3 forbids uppercase letters in a response header
key, and under Rack 2 the same example would read Content-Type. It doesn’t cover the
upgrade and connection in the full hijack — those are bytes you write to the socket
yourself, and Rack has no say in them.
Only one of the two frees a thread
This is the distinction people skip, and it’s the important one — and it’s about the mechanisms, not the versions.
In Puma, a partial hijack is called on the same thread that handled the request:
if response_hijack
fast_write_str socket, io_buffer.read_and_reset
uncork_socket socket
response_hijack.call socket
return :async
end
That sleep 1 in your proc is a sleeping Puma thread. A five-thread pool means five
concurrent SSE subscribers, and a sixth one waiting. The same is true of a Rack 3
streaming body, because it is the same line of code: moving to the newer interface
changes nothing about this arithmetic.
A full hijack is a different story. The socket leaves, the handler returns, the thread picks up the next request immediately. Except that now you’re holding an open socket that nobody is reading.
So “a hijack frees a thread” is a claim about one of the two kinds. The other one just moves the blocking out of the response body and into your code.
What Rails does with this
Rails really supplies two of them: SSE through a plain body isn’t a Rails mechanism, it’s the absence of one — Rack and nothing on top. And of the two, only one hijacks.
ActionCable hijacks fully. From ActionCable::Connection::Stream:
def hijack_rack_socket
return unless @socket_object.env["rack.hijack"]
@rack_hijack_io = @socket_object.env["rack.hijack"].call
@rack_hijack_io ||= @socket_object.env["rack.hijack_io"] # the Rack 2 fallback
@event_loop.attach(@rack_hijack_io, self)
end
Then client_socket.rb returns that same [-1, {}, []]. attach is the operative
word: the socket goes into an nio4r event loop, one loop for all the connections at
once. A thousand open WebSocket connections is a thousand file descriptors and one
thread polling them, plus a small thread pool for the actual work.
ActionController::Live doesn’t hijack at all. It sends the action off to its own
thread pool, renders into a queue, and hands that queue back as an ordinary response
body. The socket stays with the server, with all of its timeouts and its accounting. The
cost is two threads per connection: one in Puma’s pool, iterating the body and blocked
on pop, and one for the rendering itself.
The difference is entirely about where the socket lives once the response is done. A hijack buys exactly one thing: it decouples “how many open connections do I have” from “how many threads do I have.”
Where the middleware ends
The quietest part of all this.
After a full hijack the handler returns immediately — and the second half of every
middleware runs right now, while your socket is still open and the conversation
hasn’t started yet. Some of them hang off close on the body rather than off the
return: Puma closes the body in the same ensure block it just left when it returned
:async, so it makes no difference.
Rack::Deflaterwill never see your bytes.- The logger writes its line about a completed request. The connection will go on living for another four hours.
ActionDispatch::Executorcloses out the request: it returns the ActiveRecord connection to the pool, clearsCurrentAttributes, and releases the autoload interlock (that last one only in development — nothing took it in production).
That last one is the trap. Code writing to a hijacked socket is running outside the request already, and everything Rails scopes to the life of a request has been taken apart by then. Reaching for the database from a WebSocket message handler “the usual way” means reaching for something you no longer have.
ActionCable handles this head-on: app.executor.wrap around every unit of work, in
action_cable/engine.rb. It doesn’t resume the request — it runs a fresh execution of
the very executor that closed the moment the socket was hijacked.
If you’re writing your own, there’s a place to hang it too. Puma puts a
rack.after_reply array in env and runs everything in it inside that same ensure.
It isn’t part of the spec, you won’t have it on another server, and — more importantly —
it fires right after the hijack, not when the socket closes. So it’s a hook for “the
request is over,” not for “the conversation is over.” You’ll have to build the second
one yourself, and that’s exactly where descriptors usually start leaking.
A useful habit is to read a hijack as “the request ended here.” Everything after it lives by the rules of a background job, not of a controller.
Who reads this socket now
Nobody. The server has forgotten about it — both branches in Puma return :async, and
:async means “don’t write, don’t close, don’t count it toward keep-alive, this
connection is gone.”
There are two options.
A thread per connection is the fastest route to a working prototype, and also to a thread-per-connection server living inside your application server. At ten connections you won’t notice. At a thousand it’s a thousand stacks.
An event loop is what ActionCable does: one selector over every socket, handlers woken by readiness. More expensive to write, cheaper to hold.
And closing the socket is on you as well. Nobody else is going to, and an unclosed socket is a descriptor, and a process only gets so many.
Where it breaks
Timeouts aren’t yours anymore. The server doesn’t consider this connection its own, so its timeouts don’t apply to the socket. A dead client that never sent a FIN counts as alive until you set up a heartbeat yourself and start tracking when it last answered.
A deploy cuts the conversation off mid-sentence. On a graceful restart Puma waits
for the requests still running. A fully hijacked request was marked finished long before
that, so nothing waits for it — the socket dies with the process. That isn’t a bug —
it’s the price, and the client is what pays it, by knowing how to reconnect. The
ActionCable client knows how; a hand-written one knows exactly as much as you wrote into
it. With a partial hijack it’s the other way around: it’s still sitting on a pool
thread, so the restart waits for your SSE subscription. By default it waits forever —
force_shutdown_after is :forever until you set it, and Puma just joins the thread.
Two workers, two islands. Strictly speaking this is about ActionCable rather than
about hijacking, but it’s the most common way the whole thing breaks, and for the same
underlying reason: the sockets are spread across processes. The async adapter — the
one cable.yml ships for development — keeps subscriptions in the memory of one
process. As long as Puma runs a single process, you can’t tell. Turn on cluster mode and
a broadcast reaches only the clients whose connection happens to be on the worker that
sent it. Half your clients get nothing, and no error shows up anywhere. That’s what
redis is doing in the production config — or Solid Cable, if the app was generated on
Rails 8. Either way the fan-out has to travel between processes, because the connections
went and scattered across them.
Proxy buffering — that one’s about SSE. With default settings, nginx will collect
your event stream in a buffer and release it in one lump whenever it feels like it; the
fix is proxy_buffering off or the X-Accel-Buffering: no header. It also has its own
proxy_read_timeout, which knows nothing about long-lived connections. None of this
touches a connection that went to WebSocket through a 101 — that one breaks
differently: without proxy_http_version 1.1, and Upgrade and Connection passed
through, the switch never happens at all.
HTTP/2 closes this door. Behind an nginx that terminates h2, the hop to Puma is
still HTTP/1 and a hijack works fine. On a server that speaks HTTP/2 itself it doesn’t,
and the mechanism there is a different one, added in Rack 3.1: the server puts the
protocols the client advertised into env["rack.protocol"], the application answers
101 plus a response header of the same name naming one of them, and the server works
out what the switch looks like in its version of the protocol — an upgrade header in
HTTP/1, simply accepting the request in HTTP/2. Puma doesn’t implement it at all. Falcon
does.
Tests won’t help. rack-test has no socket, so neither rack.hijack nor
rack.hijack? ever shows up there and the interesting branch never runs. An integration
test will confirm that [-1, {}, []] came back and will say nothing whatsoever about
what happens next. Everything past the hijack gets verified by a real client over a real
socket.
“So why know about this in 2026?”
Fair objection. In Rack 3, partial hijack became an ordinary response body. There’s
rack.protocol for switching protocols. ActionCable already wrote the WebSockets for
you, and eight cases out of ten are served by SSE, which needs no hijack at all. There’s
almost no reason left to call env["rack.hijack"] by hand, and that’s a good thing.
The reason to know about it is a different one: it has already been called in your
application. If actioncable is in your Gemfile, then everything above — the
truncated middleware stack, the executor that closed too early, a socket outside the
server’s accounting, a conversation that won’t survive a deploy — is happening right
now. The only question is whether you know that, or whether you find out from a log
where the line saying “request completed” sits an hour above the error.
In summary
A hijack is one resource traded for another. A full one hands the thread back and takes the socket, along with the timeouts, the graceful restart, and the right-hand half of every middleware. A partial one doesn’t even hand the thread back — all it does is move the blocking into your code, and in Rack 3 it’s finally called what it always was: a response body.
It pays off in exactly one case: when open connections clearly outnumber threads, and
when there’s something to look after those connections — an event loop, a heartbeat,
reconnection on the client. All of that has to be written, and that is the real price,
not the one line with .call in it.
And if you have as many connections as you have threads, don’t take the socket. The server will do a better job with it.
Comments 0
No comments yet.