all posts
A wide diagram on cream paper. On the left, a dark felt bar titled "Standard Rails Request–Response Cycle" carries eight numbered steps, from "Client: Request" to "Client: Complete". At step seven a blue cable labelled HIJACK PATH leaves the bar and runs right, through three copper portholes — partial hijack, full hijack, socket close — and ends in a knot.

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.”

ActionController::Live2 threads: Puma blockedon pop, one morerenderingSSE through a plain body1 thread: Puma insideyour procActionCable0 threads: the socketwent to an event loopRequestSocket stays with theserverSocket taken by theapplication
What a connection costs in threads

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::Deflater will never see your bytes.
  • The logger writes its line about a completed request. The connection will go on living for another four hours.
  • ActionDispatch::Executor closes out the request: it returns the ActiveRecord connection to the pool, clears CurrentAttributes, and releases the autoload interlock (that last one only in development — nothing took it in production).
executor closed,AR connectionback in the poolGET /cablecall(env)call(env)rack.hijack.callthe socket-1, emptyresponseasynca message, anhour laterApplicationClientMiddlewarePumaClientPumaMiddlewareApplication
A full hijack, in order

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.