Retiring Rack::BodyProxy: Post-Response Hooks with rack.response_finished
The hidden truth: when does a Rack response really end?
On paper, Rack responses are just [status, headers, body]
. In reality, things get tricky with streaming. Your middleware might finish executing long before the last byte is sent. Trigger cleanup too early, and you’ll skew logs, metrics, and even user-perceived latency.
From simple arrays to streaming bodies
Early Rack apps returned plain arrays. Modern production apps rarely do. Instead, they stream data in chunks to save memory — and that complicates “after response” hooks.
1# Classic buffered response
2[200, { 'content-type' => 'text/plain' }, ['Hello Rack']]
3
4# Streaming response
5class Stream
6 def each
7 yield "Hello "
8 yield "Rack"
9 end
10end
Rack::BodyProxy: a blessing turned burden
Rack::BodyProxy
was clever—it let middleware hook into #close
. But in real-world stacks, it meant layers of allocations, garbage collector churn, and confusing metrics.
- Allocation chains: deep proxy nesting per middleware.
- GC churn: more objects ⇒ more pauses under load.
- Timing ambiguity:
#close
fires before the socket is truly done.
Meet env["rack.response_finished"]
— the new standard
Rack 3 introduces a clear, zero-guesswork API: an array of callbacks inenv["rack.response_finished"]
. Push your callable and it will run exactly once — after the response is fully complete.
Callback contract
Signature: (env, status, headers, error)
. On success, you get status/headers. On failure, you get error. Never both.
Rails integration: executors, cache cleanup & logging
Rails middleware like ActionDispatch::Executor
can now safely clean up thread locals and caches exactly when the request ends, not before. That means more accurate logs and fewer subtle memory leaks.
Migration guide: safe, incremental, and reversible
- Dual-path: use
response_finished
if present, fallback toBodyProxy
. - Instrument: log which path is used to track adoption.
- Gradually prune proxies once coverage is high.
- Verify in staging: tail latency often improves.
Best practices vs. anti-patterns
Do
- Keep callbacks idempotent & fast.
- Emit logs/metrics here, not earlier.
- Clean thread-locals & caches post-finish.
Avoid
- Long-running jobs in the callback.
- Allocating new proxies when not needed.
- Assuming callback execution order.
Tip: lightweight callbacks win
For heavy work, enqueue a background job. Pass only the essentials: request ID, status, headers, error.
Wrap-up & why you should migrate now
rack.response_finished
trims allocations, improves accuracy, and makes middleware simpler to reason about. Dual-path during rollout, then enjoy removing layers of BodyProxy cruft.
If you maintain internal middleware, now’s the best time to adopt it, add metrics, and ship a migration guide for your team.
Credits & Inspiration
Inspired by the excellent piece Friendship Ended with Rack::BodyProxy on Rails at Scale. Highly recommended for extra context.