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

ScenarioMedianP95P99DB writes on request thread
Baseline (disabled)0ms0ms0ms0
Enabled, async: true0.06ms0.85ms11.9ms0
Enabled, async: false2.2ms3.4ms5.8ms1+

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:

DatabaseTypical write costNotes
SQLite1–3msFast for development; single-writer limitation under concurrency
PostgreSQL2–5msRecommended for production; use connection pooling
MySQL2–5msSimilar 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:

  • OperationSubscriber hooks into ActiveSupport::Notifications. These are events Rails fires regardless, so the subscription cost is negligible
  • Per request: one route lookup, one Request insert, and one bulk insert for all tracked operations (SQL queries, template renders, cache reads, etc.)
  • The RequestStore accumulation 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