Performance Impact
Understand Rails Pulse's performance overhead and how to minimize its impact on your application.
Overview
Rails Pulse is designed with a minimal footprint. Instrumentation happens through ActiveSupport::Notifications, events that Rails already fires, and database writes happen on a background thread after the response has been built, so they do not hold up the request.
Benchmark Results
The following numbers were measured by running the Rails Pulse middleware stack against a passthrough Rack app (no application logic), using bin/benchmark from the Rails Pulse repository. They isolate middleware overhead from your application’s own work and were taken on the 0.4.0 release.
Environment: Ruby 3.4.7 · Rails 8.1.3 · SQLite3 · 100 iterations per scenario
| Scenario | Median | P95 | P99 | DB writes on request thread |
|---|---|---|---|---|
| Baseline (disabled) | 0ms | 0ms | 0ms | 0 |
| Enabled, async: true | 0.06ms | 0.85ms | 11.9ms | 0 |
| Enabled, async: false | 2.2ms | 3.4ms | 5.8ms | 1+ |
With async: true (the default), the request thread only packages the collected data and pushes it onto a bounded queue, so the median cost is a fraction of a millisecond. One writer thread per process drains that queue on a single database connection, and when the queue is full (config.async_queue_size, default 1000) the newest request is dropped and counted rather than blocking the app. The database writes themselves, roughly the async: false numbers, still happen, but off the request thread.
Note on DB writes: The “1+” reflects a minimal passthrough request with no tracked operations. In a real request with SQL queries, template rendering, and a controller action, Rails Pulse writes one additional record per operation tracked, typically 5–15 writes for an average page, batched into a bulk insert.
async: true vs async: false
Leave async: true in production. Set async: false when you need writes to complete before the response is sent, or in tests: transactional tests pin one database connection and share it across threads, so a background writer would interleave its statements with the test’s own. The install generator adds this line for you:
config.async = false if Rails.env.test?
The tracker also detects a shared connection at runtime and writes inline regardless of the setting.
Running your own benchmarks
# SQLite (default)
bin/benchmark --iterations=200
# PostgreSQL
DB=postgresql bin/benchmark --iterations=200
# Show history across runs
bin/benchmark --compare
Results are saved to benchmarks/results/ as timestamped JSON files so you can track changes over time or across Ruby/Rails upgrades.
Performance by Database
Write latency on the background thread varies by adapter:
| Database | Typical write cost | Notes |
|---|---|---|
| SQLite | 1–3ms | Fast for development; single-writer limitation under concurrency |
| PostgreSQL | 2–5ms | Recommended for production; use connection pooling |
| MySQL | 2–5ms | Similar to PostgreSQL with proper indexes |
Because writes are off the request thread, these numbers cost you a connection from the pool for that long rather than response time. Size your pool with one spare connection per Puma thread if you see pool timeouts.
What drives the overhead
Almost all overhead comes from persisting data to the database, not from instrumentation:
OperationSubscriberhooks intoActiveSupport::Notifications. These are events Rails fires regardless, so the subscription cost is negligible- Per request: one route lookup, one
Requestinsert, and one bulk insert for all tracked operations (SQL queries, template renders, cache reads, etc.) - The
RequestStoreaccumulation during the request is in-memory and adds no I/O cost - Exception capture is synchronous on the calling thread: one upsert for the group and one insert for the occurrence. Under an error storm this adds database latency to already-failing requests and jobs
Tracking is isolated from your app: any failure inside Rails Pulse is logged and never raises into the host request.
Minimizing overhead
Filter low-value routes
The biggest win is ignoring routes that don’t need monitoring:
RailsPulse.configure do |config|
config.track_assets = false # already the default
config.ignored_routes = [
"/health",
"/status",
%r{^/admin/system}
]
end
Tune data retention
Fewer records means faster cleanup jobs and smaller tables. The hash is merged with the defaults, so list only the tables you want to change:
RailsPulse.configure do |config|
config.full_retention_period = 7.days
config.max_table_records = {
rails_pulse_requests: 25_000,
rails_pulse_operations: 50_000,
rails_pulse_queries: 5_000,
rails_pulse_exception_occurrences: 10_000
}
end
The Storage page in the dashboard shows how full each table is against its cap.
Use a separate database
For high-traffic apps, isolating Rails Pulse writes from your main database removes contention from your primary connection pool:
RailsPulse.configure do |config|
config.connects_to = {
database: { writing: :rails_pulse, reading: :rails_pulse }
}
end
Disable in test
RailsPulse.configure do |config|
config.enabled = !Rails.env.test?
end
Relative impact by request speed
With the default background writer, the request-thread cost is well under a millisecond, so the relative impact is small even for fast endpoints. If you run async: false:
- Fast requests (< 50ms): Overhead is proportionally larger (3–10%)
- Average requests (100–500ms): Moderate impact (0.5–2%)
- Slow requests (> 1000ms): Negligible (< 0.2%)
Next Steps
- Deployment Modes — run the dashboard as a separate process to isolate its resource usage from your main app
- Database Setup — configure a dedicated Rails Pulse database for high-traffic applications