Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Ruby on Rails  Module 12 – Caching and performance

Profiling and tuning a Rails app

Measure where a request's time goes with Server-Timing and Vernier, then tune YJIT, Puma threads, workers and the connection pool.

  • Advanced
  • 20 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free core, Pro depth: 6 of 20 minutes free
Free: Three ways to see where a request's milliseconds went · Practise: predict the split, then run it. With a pass: 3 in-depth parts and 3 interview answers, about 14 minutes.

What you will learn

  • Measure where a request's milliseconds go with the Rails log, Server-Timing and Vernier's top frames
  • Choose Puma threads and workers from the share of time a request spends on database or network I/O
  • Size the Active Record connection pool to the threads, and reproduce ActiveRecord::ConnectionTimeoutError when it is smaller
  • Set a p95 budget that fails on the slow version of a request and passes on the fix
  • Decide whether to keep YJIT on from a measured speed-up and the memory it took

Before you start

What you’ll learn · Before you start

What you will learn

  • Measure where a request's milliseconds go with the Rails log, Server-Timing and Vernier's top frames
  • Choose Puma threads and workers from the share of time a request spends on database or network I/O
  • Size the Active Record connection pool to the threads, and reproduce ActiveRecord::ConnectionTimeoutError when it is smaller
  • Set a p95 budget that fails on the slow version of a request and passes on the fix
  • Decide whether to keep YJIT on from a measured speed-up and the memory it took

Before you start

On this page

The Kaksha timetable page sends 11 database queries to show 10 slots, and every teacher opens it at the start of each period. Before you change a line of code, work out where its milliseconds go: Rails’ own log already splits them, the development server adds a header that the browser’s developer tools show beside the request, and a sampling profiler: A tool that reports where a program spends its time, either by sampling what is running at regular intervals or by recording every call; sampling costs far less, so it can watch real requests. turns a run into a table of the methods that took the time.

Tuning a Rails app without a measurement is guessing. Measure one request end to end first, then change one server setting at a time and measure again: YJIT, Puma’s threads and workers, the database pool, the memory allocator, and budgets that fail the build when a page slows down again.

Three ways to see where a request’s milliseconds went

The first split needs no tool. Every controller action ends with a Completed line in the Rails log, in the form Completed 200 OK in <total>ms (Views: <ms> | ActiveRecord: <ms> (<n> queries, <n> cached) | GC: <ms>): the total, the render, the SQL with its query count, and the garbage collector’s pauses. Rails takes the queries that ran during the render out of Views, so no millisecond is counted twice.

The second split is a response header. ActionDispatch::ServerTiming collects every Active Support notification the request fires, adds up the milliseconds of each name, and writes a Server-Timing: A W3C response header in which a server reports how a request's time was spent, one name;dur=<ms> entry per metric, shown by the browser's developer tools next to the request. header of name;dur=<ms> entries, the form the W3C specification defines. A new app’s development.rb sets config.server_timing = true and production leaves it off, which suits the specification’s privacy section: these metrics can expose sensitive application and infrastructure information. The run calls the N+1 version of /timetable twice and prints both splits:

The Completed line and the Server-Timing header of a slow /timetable Ruby · server_timing.rb
require "rails"
require "active_record/railtie"
require "action_controller/railtie"
require "action_view/railtie"
require "stringio"

# A one-file Rails 8.1 app that turns Server-Timing on and prints, for one slow /timetable
# request, the Completed line of the Rails log and the response's Server-Timing header.
# The slow bit is a deliberate N+1 inside a view loop.

LOG = StringIO.new

class ServerTimingApp < Rails::Application
  config.load_defaults 8.1
  config.eager_load = false
  config.logger = ActiveSupport::Logger.new(LOG)    # the log goes to memory; the run prints its Completed line
  config.secret_key_base = "x" * 64
  config.hosts.clear
  config.server_timing = true                       # the middleware ActionDispatch::ServerTiming installs on true
  config.action_controller.perform_caching = false  # the slow version does no caching
end

ENV["DATABASE_URL"] = "sqlite3::memory:"
ServerTimingApp.initialize!

ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
  create_table :students, force: true do |t|
    t.string :name
  end
  create_table :slots, force: true do |t|
    t.integer :student_id
    t.string :period
  end
end

class Student < ActiveRecord::Base; end
class Slot < ActiveRecord::Base
  belongs_to :student
end

10.times do |i|
  s = Student.create!(name: "Student #{i + 1}")
  Slot.create!(student_id: s.id, period: "P#{i + 1}")
end

class TimetableController < ActionController::Base
  def index
    @slots = Slot.all                        # one SELECT for the slots
    render inline: <<~ERB                    # ten more SELECTs for each slot.student (N+1)
      <ul>
      <% @slots.each do |slot| %>
        <li><%= slot.period %>: <%= slot.student.name %></li>
      <% end %>
      </ul>
    ERB
  end
end

ServerTimingApp.routes.draw { get "/timetable" => "timetable#index" }

# Call the slow endpoint twice. The entry names stay the same; the milliseconds move
# from run to run. A subscriber counts the queries each request sends.
2.times do |n|
  queries = 0
  counter = ->(*) { queries += 1 }
  LOG.truncate(0)
  LOG.rewind
  status, headers, body = ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do
    ServerTimingApp.call(Rack::MockRequest.env_for("/timetable"))
  end
  puts "Request #{n + 1}: HTTP #{status}, body #{body.each.to_a.join.bytesize} bytes, #{queries} SQL queries"
  puts "Rails log: #{LOG.string.lines.grep(/\ACompleted /).last.to_s.strip}"
  entries = headers["Server-Timing"].to_s.split(", ")
  puts "Server-Timing entries (#{entries.length}):"
  entries.sort.each { |e| puts "  #{e}" }
  puts
end

Output

Request 1: HTTP 200, body 263 bytes, 11 SQL queries
Rails log: Completed 200 OK in 13ms (Views: 12.5ms | ActiveRecord: 0.4ms (11 queries, 0 cached) | GC: 0.0ms)
Server-Timing entries (5):
  instantiation.active_record;dur=0.15
  process_action.action_controller;dur=13.12
  render_template.action_view;dur=2.92
  sql.active_record;dur=0.41
  start_processing.action_controller;dur=0.01

Request 2: HTTP 200, body 263 bytes, 11 SQL queries
Rails log: Completed 200 OK in 2ms (Views: 2.1ms | ActiveRecord: 0.2ms (11 queries, 0 cached) | GC: 0.0ms)
Server-Timing entries (5):
  instantiation.active_record;dur=0.13
  process_action.action_controller;dur=2.36
  render_template.action_view;dur=2.13
  sql.active_record;dur=0.21
  start_processing.action_controller;dur=0.00

This output changes from run to run: Timings change from run to run; the page shows the recorded run.

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby server_timing.rb

Ten rows in memory keep the milliseconds small, but both requests send 11 queries: one for the slots, then one per slot for its student, because the view calls slot.student.name in its loop. That N+1 query: Reading a list with one query and then one more query per row of it, usually by following an association inside a loop; preloading the association turns the many queries into a few. More in “Associations” shows as one sql.active_record entry, the sum of all eleven. The entries overlap: process_action.action_controller times the whole action, with the render and the queries inside it. Step through the same request to see where each entry starts and what it covers:

Interactive · stage by stage Where one request's milliseconds go Step through the slow /timetable request of the example, from the browser to the Server-Timing header.
Where one request's milliseconds go, step 6 of 6. Showing Browser request, Middleware stack, Controller action, View template, Active Record (11 SELECTs, one entry), Response headers (200 OK, 5 entries). In focus: Response headers.Browser requestGET /timetableMiddleware stackServerTiming listensController actionprocess_actionView templaterender_templateActive Record11 SELECTs, one entryResponse headers200 OK, 5 entries

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The request arrives. The browser sends GET /timetable. Rails passes it down its middleware stack before any of the app's own code runs.
  2. ServerTiming starts listening. ActionDispatch::ServerTiming sits in that stack. From here it collects every Active Support notification the request fires, except names that start with "!".
  3. The action begins. start_processing.action_controller fires as the controller starts, and process_action.action_controller times the whole action from here to its end. Slot.all only builds a query here; nothing is sent yet.
  4. The view starts its loop. render_template.action_view starts timing the template. Its loop is the first code that needs the slots, so the database work begins inside the render.
  5. One query, then ten more. The loop's first step sends the SELECT for the slots, then each of the 10 slots asks for its student, one SELECT each. sql.active_record fires 11 times, all inside the render, so the two entries overlap.
  6. The header goes back. ServerTiming groups the events by name, adds up each group's milliseconds and writes one name;dur= entry per name, five for this request.

The third split names the code. Profiling answers what the first two leave open: which methods took the time. A sampling profiler: A profiler that records the running stack of each thread at a fixed interval and reports which frames were seen most often; Vernier samples every 500 microseconds by default. records what each thread runs every few hundred microseconds, so the busiest methods rise to the top. Vernier samples every 500 microseconds by default, and its README puts a profiled CPU-bound loop within a few percent of its normal speed. rack-mini-profiler adds a speed badge to every HTML page.

Profiling shows where one request spends its time; tuning a Rails app decides how many requests the server carries at once. Six settings decide that:

  • YJIT: Ruby's just-in-time compiler, built into CRuby, which turns frequently run code into machine code while the program runs: faster Ruby in exchange for more memory., Ruby’s just-in-time compiler: Rails 8.1 sets config.yjit = !Rails.env.local?, so it runs everywhere except development and test.
  • Puma threads share one process; only one runs Ruby at a time, so extra threads help only while requests wait on the database or network.
  • Puma worker: A child process that Puma forks in cluster mode; each serves requests with its own threads, so several workers run Ruby on several CPU cores at the same time. are forked processes with their own threads, so several workers use several CPU cores at once.
  • The connection pool: The set of database connections Active Record keeps per process; a thread checks one out while it needs it, and a thread that finds none free waits, then raises ActiveRecord::ConnectionTimeoutError. lends each thread a database connection; with fewer connections than threads, a thread waits and then raises ActiveRecord::ConnectionTimeoutError.
  • jemalloc: An alternative memory allocator a Rails image preloads with LD_PRELOAD, which holds less memory than the default allocator under a threaded server. and Bootsnap: A gem that caches costly steps of booting a Ruby app, such as compiling its Ruby files, so later boots are faster; the Dockerfile of a new Rails app builds its cache into the image. come with the generated Dockerfile: an allocator that fragments memory less, and a cache that makes boots faster.
  • A performance budget: A limit on a number users feel, such as the queries a page sends or its p95 time, checked by a test that fails the build when the number crosses the limit. is a test that counts a page’s queries or measures its p95 and fails the build when the page gets slower.

Checkpoint

Check: Measuring where a request's time goes

  1. Question 1 of 2 A Rails 8.1 log line reads 'Completed 200 OK in 540ms (Views: 310.0ms | ActiveRecord: 220.0ms (11 queries, 0 cached) | GC: 4.0ms)'. What does it give you?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The total, the render time, the SQL time with its query count, and the garbage collector's pauses.

    The Completed line already splits one request into its total, its render, its SQL with a query count, and its GC pauses; Rails takes the queries run during the render out of Views, so the two never overlap.

  2. Question 2 of 2 In which environment does a new Rails 8.1 app set config.server_timing = true, and why not everywhere?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Development only: in production the header would show every visitor the names and timings of the app's internals.

    Only the generated config/environments/development.rb turns Server-Timing on; production leaves it off so the names and timings of the app's notifications stay on the server.

Practise: predict the split, then run it

Copy examples/server_timing.rb, make one change at a time, and before each run write down the query count and the Server-Timing entries you expect to grow:

  1. Change Slot.all to Slot.includes(:student).
  2. Add sleep 0.05 at the start of the index action, a stand-in for a slow call to another service.
  3. Seed 100 students instead of 10, with and without the change from step 1.

Then test your predictions with the questions below, which begin with the ideas you have missed most.

Practice that adapts to you

Practise this lesson

4 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 4 A request sent 11 queries, and its Server-Timing header has one sql.active_record entry. What is that entry's dur?

    Choose one answer.

    Show the answer: Question 1 of 4

    Answer: The milliseconds of all 11 queries added up.

    One entry per notification name, its dur the sum of every event of that name: eleven queries, one sql.active_record entry.

  2. Question 2 of 4 A page takes 540 ms and you want to know why before changing code. Which of these show where the time went? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 2 of 4

    Answer:

    • A sampling profiler such as Vernier, whose report lists the busiest methods first.
    • The Rails log's Completed line, which splits the total into render, SQL and GC time.
    • The Server-Timing header in development, which the browser's developer tools show as one entry per notification name.

    The log line, the Server-Timing header and a sampling profiler each split the time more finely than the one before: by layer, by notification name, then by method.

  3. Question 3 of 4 The slow /timetable shows 10 slots and sends 11 queries, because the view asks each slot for its student. After Slot.all becomes Slot.includes(:student), how many queries does the request send?

    Type a number.

    A hint

    includes loads every slot's student in one extra query instead of one query per slot.

    Show the answer: Question 3 of 4

    Answer: 2 queries

    One query for the slots and one for all their students: 2. The count no longer grows with the number of slots, which the Completed line and the sql.active_record entry both show.

  4. Question 4 of 4 Server-Timing shows render_template.action_view at 310 ms, sql.active_record at 20 ms and cache_read.active_support at 1 ms. Which tool do you reach for next, and what do you ask it?

    Choose one answer.

    Show the answer: Question 4 of 4

    Answer: A sampling profiler on the render: which methods inside render_template.action_view take the time?

    The header has narrowed the time to the render; a sampling profiler such as Vernier names the methods inside it.

Reading Vernier's top frames, before and after a fix Pro: opens with a Pro, Premium or Ultimate pass

A profiler’s table is a list of suspects in order. Here a slow render is profiled, the fix its top row points at is applied, and a second profile shows the next suspect taking its place.

In this part:

  • What the top frames of a slow render say
  • The next suspect after the fix

1 runnable example (Ruby) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

YJIT, threads, workers and a pool that does not time out Pro: opens with a Pro, Premium or Ultimate pass

YJIT, Puma’s threads and workers, and the Active Record pool are sized together: change one without the others and the server either leaves CPU idle or fails requests at the first burst.

In this part:

  • YJIT in development and in production
  • Threads for I/O waits, workers for CPU cores
  • A pool sized to the threads, with the failure reproduced
  • jemalloc and Bootsnap, from the Dockerfile the generator writes

1 diagram · 3 runnable examples (Ruby, Text) · about 12 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

A query budget and a p95 budget your tests enforce Pro: opens with a Pro, Premium or Ultimate pass

A fix stays fixed only when a test fails on the regression. Two budgets catch most of what makes a page slow again: the number of queries a request sends, and the 95th-percentile time over a fixed number of requests.

In this part:

  • A query budget for each request a user makes
  • A p95 budget over a fixed number of requests
  • A short load test against Puma on the loopback

2 runnable examples (Ruby) · about 6 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Interview questions

What does the Server-Timing header show, and why is it on in development only?

It lists the Active Support notifications a request fired, one name;dur=<ms> entry per notification name, with the milliseconds of every event of that name added up. A Rails request shows entries such as process_action.action_controller for the whole action, render_template.action_view for the view and sql.active_record for all its queries together, and the browser’s developer tools show each entry beside the request. ActionDispatch::ServerTiming adds the header when config.server_timing is true, which a new app’s development.rb sets. Production leaves it off because every visitor’s browser would receive the names and timings of the app’s internals, the kind of information the specification’s privacy section warns about; production timings belong in a monitoring service or a profiler that keeps them on the server.

The three questions below start from symptoms seen in production. For each, say what you would measure first, then check your plan against the question’s first step.

  1. Mid to senior

    The Kaksha timetable page's p95 doubled from 180 ms to 360 ms the morning after a deploy. The pull request touched the view, a serialiser and a before action. Walk through how you would find which change doubled the time, from the Rails log to a sampling profiler.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A new four-core server runs the Kaksha API. Measured on the current workload, requests spend about forty percent of their wall time waiting on PostgreSQL. Choose Puma threads, Puma workers and the Active Record pool size for one server, and defend the numbers.

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    After YJIT went on in production, each Kaksha API worker's memory rose from 480 MB to about 530 MB and p95 fell from 110 ms to 82 ms. The 8 GB server runs four workers, and a memory alert fired. Would you keep YJIT on or turn it off, and what would you measure next?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What does the Completed line at the end of each request in the Rails log tell you?

    The total time, the time spent rendering views, the SQL time with its query count and cached queries, and the garbage collector's pauses. Queries run during the render are taken out of Views, so no millisecond is counted twice.

  2. What does the Server-Timing header list in development, and why is it off in production?

    One name;dur= entry per Active Support notification name, with the milliseconds of every event of that name added up, so an N+1 shows as one large sql.active_record entry. Production leaves it off because such metrics can expose sensitive details about the app and its infrastructure.

  3. What does a sampling profiler such as Vernier add to the log and the header?

    The names of the methods that took the time. It records what each thread is running every few hundred microseconds, 500 by default in Vernier, so the busiest methods rise to the top.

  4. Where does YJIT run in a Rails 8.1 app?

    Everywhere except development and test: the generated setting is config.yjit = !Rails.env.local?.

  5. When do more Puma threads help, and when do you need more workers?

    Threads share one process and only one runs Ruby at a time, so extra threads help only while requests wait on the database or the network. Workers are forked processes with their own threads, so several of them use several CPU cores.

  6. What happens when the connection pool is smaller than the number of threads, and what does a performance budget do?

    Threads beyond the pool's size wait for a connection to come back, and one that waits too long raises ActiveRecord::ConnectionTimeoutError. A performance budget is a test that counts a page's queries or measures its p95 and fails the build when the page gets slower.

Key takeaways

  • The Rails log’s Completed line splits a request into total, render, SQL and GC time and counts its queries.
  • In development, the Server-Timing header adds one entry per notification name, so an N+1 shows as many queries summed into one sql.active_record entry.
  • A sampling profiler such as Vernier names the methods behind the time and costs little enough to point at a real request.
  • YJIT, Puma’s threads and workers, the connection pool, jemalloc with Bootsnap, and performance budgets decide how fast a Rails server answers, and each is worth changing only with a measurement before and after.

Terms in this lesson

Bootsnap
A gem that caches costly steps of booting a Ruby app, such as compiling its Ruby files, so later boots are faster; the Dockerfile of a new Rails app builds its cache into the image.
connection pool
The set of database connections Active Record keeps per process; a thread checks one out while it needs it, and a thread that finds none free waits, then raises ActiveRecord::ConnectionTimeoutError.
flame graph
A drawing of a sampled profile as stacked boxes, one per frame, each as wide as the share of samples that passed through it; left-to-right order carries no meaning.
GVL
CRuby's Global VM Lock, which lets only one thread of a process run Ruby code at a time; the others may wait on I/O meanwhile, so threads help while requests wait.
load test
A run of many requests against a server to measure how many it answers a second and how long the typical and the slowest requests take under that load.
performance budget
A limit on a number users feel, such as the queries a page sends or its p95 time, checked by a test that fails the build when the number crosses the limit.
profiler
A tool that reports where a program spends its time, either by sampling what is running at regular intervals or by recording every call; sampling costs far less, so it can watch real requests.
Puma worker
A child process that Puma forks in cluster mode; each serves requests with its own threads, so several workers run Ruby on several CPU cores at the same time.
sampling profiler
A profiler that records the running stack of each thread at a fixed interval and reports which frames were seen most often; Vernier samples every 500 microseconds by default.
Server-Timing
A W3C response header in which a server reports how a request's time was spent, one name;dur=<ms> entry per metric, shown by the browser's developer tools next to the request.
tail latency
The latency of the slowest requests, reported as a high percentile such as p95 or p99; an average hides it, though it is what the unluckiest users wait.
YJIT
Ruby's just-in-time compiler, built into CRuby, which turns frequently run code into machine code while the program runs: faster Ruby in exchange for more memory.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.