Rails Timeouts: Statement, Rack, HTTP Client and Job Timeouts That Prevent Production Cascades
Rails timeouts done right: configure statement_timeout, rack-timeout, HTTP client and background job limits to prevent cascading production failures in Rails.
The pager fires at 14:12 on a Wednesday afternoon. A checkout page is timing out. The team looks at the checkout code and finds nothing. They look at Postgres and find a single query on a products table that has been running for eleven minutes. They look at Puma and find every worker stuck on that same request. They look at Sidekiq and find the retry queue climbing. They look at the CDN and find requests piling up in the proxy. Nothing is broken. Everything is waiting.
I have walked into this exact incident four times in the last two years. Different companies, different products, the same shape. One slow thing in one place, no timeout anywhere in the stack to stop it, and the entire application freezes because nothing is willing to give up. After nineteen years of Rails I have come to believe that Rails timeouts are not a nice-to-have — they are the single cheapest reliability upgrade you can apply to a production system, and nobody teaches them until the outage happens.
This post is what I install on every serious Rails production system on day one: statement timeouts in Postgres, request timeouts at the Rack layer, per-client HTTP timeouts, and per-job timeouts in the queue. Set correctly, they do not fix your bugs — they prevent one bug from becoming an outage.
Why Rails Timeouts Are Not Optional
Every I/O call in a Rails process is a bet. You bet the database will answer within a reasonable time. You bet Stripe will return under a second. You bet the customer’s browser will read the response before the connection dies. Ninety-nine times out of a hundred you win. The hundredth time is the outage.
Without timeouts, the outage does not stay local. Rails is a synchronous framework by default: a Puma worker holds a thread for the entire duration of a request. If a single database query hangs for eleven minutes, that worker is unavailable for eleven minutes. If ten workers all hit the same slow query, your entire application is unavailable. This is what people mean by cascading failures: the initial fault is contained, but the lack of timeouts converts it into a stampede.
Rails timeouts are the circuit breakers that stop the stampede. Each one says: “if this call has not returned by X, give up, free the thread, and let something else run.” That is all. It is not fancy. It is not glamorous. It is the difference between a 500 on one endpoint and a full site outage.
The Four Layers of Rails Timeouts
Every Rails request touches four layers where a call can hang, and each layer has its own timeout mechanism. You need all four, and they need to nest correctly.
- Postgres statement timeout — the database refuses to run a single statement past N seconds.
- Rack request timeout — the web layer aborts a request that has not completed after N seconds.
- HTTP client timeout — every outbound HTTP call has connect, read, and write timeouts.
- Background job timeout — the queue kills a job that has run past its allotted time.
The rule that ties them together is one line: inner timeouts must be shorter than outer timeouts. If your Rack timeout is 15 seconds and your Postgres statement_timeout is 30 seconds, the Rack layer will kill the request before Postgres notices. The query keeps running, the connection is returned to the pool while still busy, and the next unlucky request inherits a poisoned connection. Nested correctly, each layer catches its own faults before the outer one has to.
Postgres statement_timeout in a Rails App
The single highest-leverage timeout on the list. statement_timeout is a Postgres setting that cancels any statement running longer than the configured value. It is per-session, so you can set it globally, per-role, per-connection, or per-transaction. In Rails, the correct default is to set it per-role — one value for the web workers, a different one for background jobs, and no timeout for the migration user.
Set it on the role at the database level:
ALTER ROLE ttb_web SET statement_timeout = '5s';
ALTER ROLE ttb_worker SET statement_timeout = '30s';
ALTER ROLE ttb_migrate SET statement_timeout = 0;
Any connection made by ttb_web will now automatically get a 5-second limit on every query. This is where I always start — five seconds is generous for a web request and catches the ninety-ninth percentile of runaway queries before they eat a worker.
For the rare endpoint that legitimately needs longer — a report, a bulk export — override in the controller with a scoped transaction:
class ReportsController < ApplicationController
def annual_summary
ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute("SET LOCAL statement_timeout = '60s'")
@report = AnnualSummary.new(current_account).build
end
render :annual_summary
end
end
SET LOCAL scopes the change to the current transaction and reverts automatically at commit or rollback, so the next request on this connection still gets the 5-second default. This is the only pattern I trust for one-off overrides. SET statement_timeout without LOCAL sticks to the connection for the rest of its life, which is exactly the bug you do not want to debug at 3am.
You can also set lock_timeout on the same role to fail fast on lock contention — most Rails apps want it at one or two seconds so a stuck migration cannot block every subsequent request. If you have not seen Rails Strong Migrations, it pairs well here: strong-migrations blocks the unsafe migration at development time, and lock_timeout limits the damage of the ones that slip through.
rack-timeout for Request-Level Rails Timeouts
Postgres timeouts only catch database work. If a Ruby-level infinite loop or a hung HTTP call fires inside a controller, Postgres will never see it and the request will run forever. The rack-timeout gem inserts itself as middleware and raises an exception in the worker thread once the total request time crosses a threshold.
Add it to the Gemfile and configure it in an initializer:
# Gemfile
gem "rack-timeout"
# config/initializers/rack_timeout.rb
Rack::Timeout.service_timeout = 15 # kill the request after 15s
Rack::Timeout.wait_timeout = 30 # kill if it waited > 30s to start
Rack::Timeout.service_past_wait = false # do not run if we are already late
Three settings, all important. service_timeout is the hard ceiling on request handling time — anything past this raises Rack::Timeout::RequestTimeoutException. wait_timeout accounts for time the request spent queued in Puma before a worker picked it up; if the queue is 30 seconds deep, the request has already spent its useful life waiting and there is no point running it. service_past_wait set to false means “do not even start work on a request that has already exceeded its wait time” — with it on, rack-timeout drops the load-shed decision one hop earlier, before any Ruby code runs.
service_timeout should be shorter than your load balancer’s upstream timeout. If the ELB gives up at 30s and Rails does not, the ELB returns a 504 while the Puma worker is still holding the thread. The next request retries, hits the same slow path, and now you have two hung workers instead of one. Fifteen seconds inside Rails and thirty seconds at the ELB is the pattern I use most often.
The exception rack-timeout raises is a real Ruby exception, which means your error tracker sees it. Group these carefully — a Rack::Timeout::RequestTimeoutException is not a bug, it is a signal that a specific endpoint is slow. Route them to a Slack channel and treat them as a work queue.
HTTP Client Timeouts for Every Outbound Call
The most common way I see Rails apps fall over in 2026 is not a slow query — it is a slow external API. LLM providers, payment processors, geocoders, webhooks. When they slow down, Rails workers slow down with them, and every default Ruby HTTP client has an infinite timeout unless you set one.
Two defaults you must change before any external call. First, Net::HTTP:
require "net/http"
uri = URI("https://api.stripe.com/v1/charges")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = 2 # TCP + TLS handshake
http.read_timeout = 5 # bytes on the socket after the connect
http.write_timeout = 5 # rare, matters for large uploads
request = Net::HTTP::Get.new(uri)
http.request(request)
The defaults for Net::HTTP are effectively unbounded. If Stripe stops responding, your Puma worker will sit on the socket until the OS decides otherwise — which can be minutes. Setting open_timeout, read_timeout, and write_timeout explicitly is not optional. I use two seconds for connect and five seconds for read as a starting point for anything hosted on a healthy public API. LLM streaming responses are the exception: a hosted model can legitimately take thirty seconds, so read_timeout there is longer, but every other call should be well under ten seconds.
For Faraday, the equivalent lives on the connection:
connection = Faraday.new(url: "https://api.stripe.com") do |f|
f.request :retry, max: 2, interval: 0.2, exceptions: [Faraday::TimeoutError]
f.response :raise_error
f.options.open_timeout = 2
f.options.timeout = 5
end
Faraday’s timeout maps to the underlying adapter’s read timeout. If you are using Faraday with Net::HTTP as the adapter, you get all of Net::HTTP’s defaults unless you override — which is another way of saying “no timeout” for anything except the fields Faraday explicitly forwards. Set them.
For the OpenAI, Anthropic, and other LLM gems, check the timeout options in the client config — most of them wrap Faraday and expose a request_timeout argument on initialization. Do not accept the default; the vendor libraries ship with values calibrated for happy weather, not for the 3am incident.
If you want defense in depth on top of this, wrap external calls in a circuit breaker. Circuitbox or Semian will open the circuit after N failures and start rejecting calls immediately instead of waiting for each one to time out. Timeouts alone stop a single call from blocking a worker; a circuit breaker stops the hundredth call from ever being made once it is clear the dependency is down.
Background Job Timeouts (SolidQueue and Sidekiq)
Background jobs are the layer where teams most often forget timeouts entirely, on the theory that “it is fine if a job takes a while.” It is not. A job that runs forever holds a worker slot forever, and enough of them will fill the queue. Every job needs a wall-clock ceiling.
For SolidQueue in Rails 8, set dispatcher.polling_interval and the job’s own Timeout.timeout block. SolidQueue does not enforce an outer timeout on a running job — you enforce it inside the job:
class GenerateReportJob < ApplicationJob
queue_as :default
MAX_DURATION = 120.seconds
def perform(report_id)
Timeout.timeout(MAX_DURATION) do
Report.find(report_id).generate!
end
rescue Timeout::Error => e
Rails.error.report(e, context: { report_id: report_id })
Report.find(report_id).mark_timed_out!
raise # let ActiveJob's retry backoff decide what happens next
end
end
Timeout.timeout has a bad reputation in some corners of the Ruby community — it raises across threads and can leave file handles or connections in odd states. For a job that owns its own Postgres connection and nothing else, it is fine. For jobs that touch shared external state — file locks, distributed mutexes — prefer an explicit deadline check inside the job and abort cleanly at natural boundaries.
Sidekiq gives you a per-job timeout via the sidekiq-timeout gem or the sidekiq_options timeout: from Sidekiq Enterprise. For most projects on the free tier, wrap the body in Timeout.timeout and be done. Combine this with Rails ActiveJob retries with exponential backoff so a timed-out job comes back on the next attempt without a human touching it.
Cascading Timeouts: How the Numbers Fit Together
Here is the concrete recipe I install on a fresh production Rails system. Every number nests inside the next.
- ELB / CloudFront upstream timeout: 30s
rack-timeoutservice_timeout: 15srack-timeoutwait_timeout: 30s- Postgres statement_timeout for
ttb_webrole: 5s - Postgres lock_timeout for
ttb_webrole: 2s - HTTP client
open_timeout: 2s - HTTP client
read_timeout: 5s (LLMs: 30s) - Background job wall-clock timeout: 120s (or the job’s own explicit ceiling)
- Puma worker restart on OOM (via
puma_worker_killerorPUMA_WORKER_MEMORY_LIMIT): 1 GB
Read down the list from the outside in. The ELB will kill a request at 30s. Rails will kill it at 15s. Postgres will refuse a single query at 5s. Any HTTP call inside that request has 5s to return. If any of those timers fire, the request errors out cleanly, the worker returns to the pool, and the next request runs. The cascading part is that the inner layer always fires first — the database gives up before Rails does, Rails gives up before the ELB does. The bulkhead never breaks all the way through.
The times themselves are starting values, not law. Adjust them per endpoint if you must — but adjust them explicitly, in code, with a comment explaining why. The one thing you must not do is remove them.
The 3am Test: Have You Actually Set These?
Every Rails project I do due diligence on gets the same three checks in the first hour. If you have not run these yourself, do it before the next deploy.
Check one. SSH into a production box, bundle exec rails runner "puts ActiveRecord::Base.connection.execute(%q{SHOW statement_timeout}).first". If it says 0, you have no database timeout. Add one on the role today.
Check two. grep -r "Rack::Timeout" config/ in your Rails app. If it returns nothing, install the gem, set service_timeout to 15s, and deploy. This is a five-line change that has saved every one of the four teams I mentioned in the opening.
Check three. Open the code for your most-called external service — Stripe, Twilio, OpenAI, whatever it is. Search for open_timeout and read_timeout. If they are not set, set them. Two seconds and five seconds are the right starting numbers.
None of this is glamorous. None of it makes the product better. All of it turns a two-hour outage into a fifteen-second blip that the on-call engineer notices in Sentry and shrugs at. That is the trade you are making. I have never met a founder who regretted it.
FAQ
What is a good value for Rails statement_timeout in production?
Five seconds is the right default for a web-facing Postgres role. Ninety-plus percent of legitimate application queries in a well-indexed Rails app return in under 500 milliseconds; five seconds catches the runaway queries without breaking the tail latency of legitimate slow endpoints. Reports and exports that need longer should scope an override with SET LOCAL statement_timeout inside a transaction, not by raising the global default.
Does rack-timeout work with Puma in Rails 8?
Yes. rack-timeout is transport-agnostic middleware and runs identically under Puma, Unicorn, and Falcon. On Puma the important detail is that wait_timeout measures time spent in Puma’s request queue before a worker picks up the request — pair a short wait_timeout with service_past_wait = false and Puma will shed load automatically when workers are saturated instead of running requests that clients have already given up on.
Do I need timeouts on background jobs if I already have retries?
Yes. Retries decide what happens after a job fails; timeouts decide when a job fails. Without a timeout, a runaway job holds a worker forever and never triggers a retry at all. The two mechanisms work together: the timeout puts a ceiling on any single attempt, and the retry policy — with exponential backoff — decides whether to try again.
How do I set different Rails timeouts for admin endpoints and public endpoints?
The cleanest pattern is a separate Postgres role and a controller-level rack-timeout override. Give admin actions a role with a longer statement_timeout, connect through it via a separate connection pool on a specific ActiveRecord::Base subclass, and override the rack-timeout service ceiling in the controller with Rack::Timeout.override_timeout_for_this_request(60). Do not raise the global defaults for the whole application to accommodate a few slow admin pages.
Need help hardening a Rails application against production cascades? TTB Software specializes in Rails reliability, performance, and fractional CTO work. We have been doing this for nineteen years and we install these timeouts on every project we touch.
Related Articles
Rails Postgres Backups with pgBackRest: Point-in-Time Recovery, S3 Storage, and Restore Drills
Rails Postgres backups with pgBackRest: full and differential backups to S3, WAL archiving, point-in-time recovery, a...
Rails Searchkick: Production Full-Text Search with Elasticsearch and OpenSearch
Rails Searchkick brings Elasticsearch and OpenSearch to ActiveRecord with synonyms, boosting, facets, autocomplete, a...
Rails Composite Primary Keys: CPK, Legacy Schemas, and Natural Keys in ActiveRecord
Rails composite primary keys let ActiveRecord model multi-column PKs natively. Learn CPK setup, associations, legacy ...