← Back to blog

Retiring Rack::BodyProxy: Post-Response Hooks with rack.response_finished

September 2025
3 min read
Rack
Rails
Middleware
Performance
Spec

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

  1. Dual-path your middleware. Prefer the hook; otherwise keep your existing BodyProxy logic.
  2. Instrument both paths. Log which branch is used so you see adoption over time.
  3. Gradually remove proxies. Once most traffic uses the hook, prune proxy wrappers to cut allocations.
  4. 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.

Ready to simplify your middleware and get accurate post-response metrics?
Need a hand modernizing your Rack/Rails middleware or trimming GC pressure in production? I can help design, benchmark, and roll out the migration.