Retiring Rack::BodyProxy: Post-Response Hooks with rack.response_finished
Context: the real “end” of a response
In Rack, the triplet [status, headers, body]
looks deceptively simple. What trips teams up is when the response genuinely ends. If your body is a streaming enumerator, your middleware’s call
will finish long before the last byte leaves the socket. Any “after response” work triggered too early risks skewing logs, metrics, and user-visible latency.
Spec evolution: from arrays to streamed bodies
Early tutorials returned simple arrays of strings. Production apps rarely do that: they stream. Rack only requires that a body respond to #each
and yield strings. That allows chunked, low-memory responses—but complicates “afterwards” hooks.
1# Minimal, buffered body
2class App
3 def call(env)
4 [200, { 'content-type' => 'text/plain' }, ['Hello Rack']]
5 end
6end
7
8# Streamed body (preferred in real apps)
9class Stream
10 def each
11 yield "Hello "
12 yield "Rack"
13 end
14end
15
16class StreamingApp
17 def call(env)
18 [200, { 'content-type' => 'text/plain' }, Stream.new]
19 end
20end
Where BodyProxy helped — and where it hurt
Rack::BodyProxy
wrapped the body to intercept #close
, giving middleware a place to run cleanup. It unblocked a generation of tooling, but its costs compounded in large stacks.
- Allocation chains: each middleware adds a proxy → deep nesting.
- GC churn: more objects ⇒ more pauses under high traffic.
- Timing ambiguity:
#close
can run while the socket is still considered open upstream, confusing “time to last byte” metrics.
1class LoggerMiddleware
2 def call(env)
3 status, headers, body = @app.call(env)
4 body = Rack::BodyProxy.new(body) { logger.info("done") }
5 [status, headers, body]
6 end
7end
The hook: env["rack.response_finished"]
Rack 3 standardizes a cleaner signal: servers expose an array at env["rack.response_finished"]
. Append a callable that will run exactly once—after the response is fully completed. No proxy objects; no guesswork.
1class LoggerMiddleware
2 def initialize
3 @on_finish = ->(env, status, headers, error) {
4 # status/headers are present on success; error is present on failure
5 logger.info("finished: #{status || 'error'}")
6 }
7 end
8
9 def call(env)
10 env["rack.response_finished"]&.push(@on_finish)
11 @app.call(env)
12 end
13end
Callback signature contract
The callable receives (env, status, headers, error)
. Either status/headers or error will be set—not both.
Server support & signaling
Modern servers surface the hook directly in env
. If it’s missing, fall back to your legacy strategy (see migration). You can also feature-detect in tests and exercise both paths.
1# Feature detection helper for tests
2def supports_response_finished?(app)
3 env = {}
4 app.call(env) rescue nil
5 env.key?("rack.response_finished")
6end
Rails integration: executors, caching, and logs
Rails middleware like ActionDispatch::Executor
can clear per-request state when the response really finishes, preserving context for request summaries and ensuring caches and local resources don’t leak across requests.
1# Example: clear thread-local cache at the truly-final moment
2class LocalCacheReaper
3 def initialize(app) = @app = app
4
5 CLEANUP = ->(_env, _status, _headers, _error) { ActiveSupport::Cache::Strategy::LocalCache.clear_for! Thread.current }
6
7 def call(env)
8 env["rack.response_finished"]&.push(CLEANUP)
9 @app.call(env)
10 end
11end
Migration guide: safe, incremental, reversible
- Dual-path your middleware. Prefer the hook; otherwise keep your existing
BodyProxy
logic. - Instrument both paths. Log which branch is used so you see adoption over time.
- Gradually remove proxies. Once most traffic uses the hook, prune proxy wrappers to cut allocations.
- Verify in staging. Check connection counts and tail latency—these often improve when cleanup happens after the socket closes.
1class DualPathMiddleware
2 def initialize(app) = @app = app
3
4 def call(env)
5 if (rf = env["rack.response_finished"])
6 rf << ->(e, s, h, err) { Metrics.emit(e, s, h, err) }
7 @app.call(env)
8 else
9 status, headers, body = @app.call(env)
10 body = Rack::BodyProxy.new(body) { Metrics.emit(env, status, headers, nil) }
11 [status, headers, body]
12 end
13 end
14end
Patterns & anti-patterns
Do
- Keep callbacks idempotent and side-effect light.
- Capture only what you need—avoid capturing large objects.
- Emit metrics/logs here, not earlier, for accurate timings.
- Clean thread-locals and per-request caches post-finish.
Avoid
- Long-running synchronous work in the callback.
- Allocating new proxies when the hook is available.
- Swallowing exceptions—surface and observe them.
- Relying on implicit ordering between multiple callbacks.
Tip: small, bounded callbacks
If you need heavier work, enqueue a background job and pass only the minimal context (request ID, status, headers hash, error message).
FAQ: edge cases and gotchas
What if the body never calls #close
?
That’s exactly why the standardized hook is superior: you’re not depending on a proxied #close
being invoked. Servers drive the lifecycle and call your callback when they actually finish the response.
Can multiple middlewares register callbacks?
Yes. Treat the array as append-only and do not assume execution order among peers. If ordering matters, consolidate into a single coordinator middleware.
How do I observe callback errors?
Wrap your callable and report failures to your error tracker. Do not raise from the callback—by the time it runs, the response is gone.
Wrap-up
rack.response_finished
is the missing piece for precise, low-overhead post-response work. It trims allocations, improves metrics accuracy, and leads to cleaner middleware architecture. Keep a dual path while you roll it out—and then enjoy removing layers of proxies you no longer need.
If you maintain internal middleware, now is the ideal time to adopt the hook, add metrics around it, and publish a short migration note for your teams.
Credits & Inspiration
This article was inspired by the excellent write-up Friendship Ended with Rack::BodyProxy published on Rails at Scale. I recommend checking it out for additional context and background.