← Back to Blog

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

Published September 1, 2025 · Updated September 17, 2025
3 min read
Rack 3
Rails
Middleware
Performance
Migration

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

  1. Dual-path: use response_finished if present, fallback to BodyProxy.
  2. Instrument: log which path is used to track adoption.
  3. Gradually prune proxies once coverage is high.
  4. 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.

Ready to modernize your Rack middleware?
Need expert help migrating away from BodyProxy or reducing GC pressure in high-traffic Rack apps? I can help design, benchmark, and roll out a safe migration.