Diagnosing and Resolving Heroku R10 Request Timeout Errors
A step-by-step diagnostic guide for Heroku R10 request timeout errors: recognize the condition, correlate logs with APM traces, identify root causes (blocking I/O, unoptimized queries, memory pressure, thread exhaustion), apply targeted fixes, and know when to escalate.
30 Mar 2026, 18:18 UTC

Recognizing the R10 Condition
Your application returns HTTP 503 responses and Heroku logs show R10 - Request timeout entries. The router terminated the connection after 30 seconds because the web dyno did not send a complete response. This is a hard platform limit—no configuration change can extend it.
Common Causes and Quick Diagnosis
| Symptom in Logs | Likely Cause | First Check |
|---|---|---|
R10 with no preceding H12 | Application code blocked >30s (sync HTTP call, heavy computation, unoptimized query) | Correlate timestamp with APM trace for the same request ID |
R10 followed by H14 (Memory quota exceeded) | Memory pressure causing swapping/GC pauses that push response past 30s | Check heroku metrics memory for the dyno at that time |
Intermittent R10 under load | Thread/connection pool exhaustion; requests queue in the dyno | Review concurrency settings (Puma workers, Gunicorn workers, Node cluster size) |
R10 on specific endpoints only | Known long-running code path (report generation, bulk export, external API fan-out) | Add request-level timing logs to that endpoint |
Ordered Diagnostic Checks
- Confirm the error class. Run
heroku logs --tail --source routerand filter forR10. Note the request IDs and timestamps. - Pull the application trace. Using your APM (New Relic, Datadog, Scout, or Heroku's built-in
heroku logs --dyno=web), locate the request by ID. Identify which middleware, controller, or view consumed the majority of the 30s window. - Check memory pressure. Execute
heroku metrics memory --dyno web.1for the relevant period. If usage approaches the dyno limit (512 MB for standard-1x, 1 GB for standard-2x), the process may be swapping or spending cycles in GC. - Inspect database query plans. For the slow endpoint, run
EXPLAIN ANALYZEon the generated SQL. Look for sequential scans, missing indexes, or N+1 query patterns. - Audit external HTTP calls. Search the codebase for outbound requests (Stripe, SendGrid, third-party APIs) that lack explicit timeouts. A hanging external call blocks the dyno thread indefinitely.
- Verify concurrency configuration. For Ruby/Puma:
WEB_CONCURRENCYandRAILS_MAX_THREADS. For Python/Gunicorn:WEB_CONCURRENCYand--threads. For Node: cluster worker count. Ensure the product of workers × threads matches expected concurrent request capacity.
Fixes Mapped to Findings
Finding: Synchronous external API call exceeds 30s
Fix: Move the call to a background job. Example with Sidekiq (Ruby) or Celery (Python):
# Before (blocks web dyno)
def create
response = HTTParty.post('https://api.payment-gateway.com/charge', timeout: 60)
# ...
end
# After (returns immediately)
def create
ChargeJob.perform_later(params[:amount], current_user.id)
render json: { status: 'queued' }, status: :accepted
end
Deploy a worker dyno (heroku ps:scale worker=1) and ensure the queue backend (Redis via Heroku Redis) is provisioned.
Finding: Unoptimized query causes sequential scan
Fix: Add the missing index. For a Rails migration:
class AddIndexToOrdersUserId < ActiveRecord::Migration[7.1]
def change
add_index :orders, :user_id, algorithm: :concurrently
end
end
Run heroku run rake db:migrate during a maintenance window; algorithm: :concurrently avoids locking the table on PostgreSQL 12+.
Finding: Memory pressure triggers GC pauses
Fix: Reduce per-request memory allocation. Common culprits:
- Rendering large JSON payloads without streaming (
render json: huge_collection→ userender stream: trueor paginate) - Loading entire CSV/Excel exports into memory (use
CSV.generatewith a block oraxlsxstreaming) - N+1 associations (add
includes(:association)orpreload)
standard-2x or performance-m as a temporary measure while refactoring.
Finding: Thread pool exhaustion under burst traffic
Fix: Tune concurrency to match I/O-bound workload. For a Puma config (config/puma.rb):
workers Integer(ENV.fetch('WEB_CONCURRENCY', 2))
threads_count = Integer(ENV.fetch('RAILS_MAX_THREADS', 5))
threads threads_count, threads_count
preload_app!
on_worker_boot do
ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
end
Set WEB_CONCURRENCY=3 and RAILS_MAX_THREADS=5 on a standard-1x dyno (3 × 5 = 15 concurrent requests). Monitor heroku logs --tail for R14 (memory quota exceeded) after the change.
Escalation Criteria
Engage Heroku Support or a platform engineer when:
- R10 errors persist after moving all identified long-running work to background workers and adding appropriate indexes.
- Memory usage remains below 70% of the dyno limit but requests still time out—this suggests a kernel-level or router-side issue.
- You observe
H99(platform error) correlated with R10 spikes, indicating a possible routing-layer degradation.
Open a ticket with heroku support:create and include: request IDs, timestamps, APM trace links, and the output of heroku logs --source router --num 1500 covering the incident window.
Verification After Fix
- Deploy the change to staging. Run a load test (
hey -n 500 -c 20 https://staging.example.com/slow-endpoint) and confirm zero R10 entries inheroku logs --source router. - Promote to production. Enable
heroku labs:enable log-runtime-metricsto capture per-dyno memory/CPU in logs for 24 hours. - Set up an alert:
heroku alerts:add --metric router.request.timeout --threshold 1 --window 5m(requires Heroku Alerting add-on or external log drain to Datadog/Splunk).
Limitations
- The 30-second router timeout is immutable. Architectural changes (async processing, streaming responses) are the only path for genuinely long operations.
- Increasing dyno size masks CPU/memory pressure but does not fix blocking code; the same R10 will recur under higher load.
- Background workers introduce eventual consistency—clients must poll or use WebSockets for completion notification.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.