Rails performance

Using memory_profiler to reduce Rails memory pressure

How to use memory_profiler as part of a broader Rails memory investigation: allocations, query shapes, object graphs, and practical fixes.

What memory_profiler measures

memory_profiler is useful because it counts Ruby object allocations and retained objects. It does not explain every kind of memory growth, but it gives a concrete view of what a block of Ruby code creates.

That is especially valuable in Rails, where a request can allocate through Active Record, serializers, view rendering, JSON generation, validations, callbacks, and framework middleware before the application code looks suspicious.

The report is not a verdict. It is a map for choosing where to look next.

Run a small case first

The best profiler run is narrow. Wrap one request path, one service call, one serializer, or one query loop. A whole test suite or broad boot path produces too much noise.

Use realistic data, but keep the case repeatable. If the production issue happens with 500 records, profile 50, 500, and 5,000 records separately. Growth shape matters more than one impressive number.

Also warm the app before measuring when boot allocations are not the target. Rails initialization can drown out the request behavior being investigated.

Read the report

The useful sections are allocated objects, retained objects, allocated memory, retained memory, and allocation locations. Allocated objects show churn. Retained objects show what stays reachable after the block finishes.

High allocation is not automatically bad. Short-lived objects may be acceptable if latency and garbage collection remain fine. Retained objects are often more interesting for memory growth because they suggest caches, global structures, or references that survive longer than expected.

Read class names together with file locations. A large number of strings from JSON rendering suggests a different fix than many Active Record objects from an eager-loaded association.

Common Rails fixes

The fix often starts with query shape. Loading full Active Record objects for a report that only needs ids and names wastes memory. Use pluck, select, batching, or streaming where the domain allows it.

Serialization is another common source. Rebuilding the same nested hashes, formatting dates repeatedly, or rendering huge JSON arrays in memory can dominate allocations. Sometimes the answer is pagination. Sometimes it is a simpler response shape.

Cache carefully. Caching can reduce allocations in one path while retaining large object graphs globally. The profiler helps verify which side of that trade-off is actually happening.

Memory pressure beyond Ruby objects

memory_profiler does not fully explain native memory, allocator fragmentation, database client buffers, image libraries, or memory held by extensions. It should be paired with process-level observation when RSS is the symptom.

Ruby GC stats can show whether the heap is growing, how often GC runs, and whether old objects accumulate. OS tools show the process footprint. Database logs show whether the request is also causing large result sets or temp files.

The practical workflow is to use memory_profiler to reduce obvious Ruby waste, then verify the real process memory under the workload that caused the problem.

From report to fix

The report should lead to one small experiment at a time. Replace a full-object load with pluck, remove a repeated string transformation, batch a loop, or simplify a serializer, then rerun the same measurement. Changing several things at once makes the result harder to trust.

Keep the final fix tied to the user-visible symptom: lower request RSS, fewer allocations in a hot path, shorter GC pauses, or safer batch size. A prettier allocation report is useful only if the process behaves better under the workload that mattered.

I extended this measurement-first approach in Rails Dependency Pruner, an experimental Rails 8 tool that combines Prism-based static analysis with optional runtime evidence before producing a reviewable boot-pruning profile.

Profiling a request shape

memory_profiler is most useful when the measured block is narrow. A whole test suite report is usually noise. A single serializer, importer, or endpoint path gives you something actionable.

require "memory_profiler"

report = MemoryProfiler.report do
  100.times do
    Articles::IndexSerializer.new(Article.limit(50).to_a).as_json
  end
end

report.pretty_print(
  scale_bytes: true,
  allocated_strings: 20,
  retained_strings: 20
)

Look separately at allocated and retained objects. Allocations create garbage collection pressure, but retained objects keep memory alive after the block finishes. A view that allocates many temporary strings is a different problem from a cache that retains a large graph.

The next step should be a hypothesis, not blind optimization: remove one conversion, freeze one repeated string, preload one association, or avoid building one intermediate array, then run the same report again.

What the report does not tell you

A memory profile is a microscope, not a capacity plan. It can show which lines allocate objects inside the measured block, but it does not automatically explain process RSS, allocator fragmentation, copy-on-write behavior, or memory retained by native extensions. Those questions need process-level tools as well.

The report is still valuable because it narrows the search. If one serializer allocates thousands of duplicate strings, the fix may be local. If retained objects point at a cache, class variable, or global registry, the fix is a lifecycle decision. The important habit is to connect each optimization to a repeated measurement.

Sources

  1. memory_profiler gem. https://github.com/SamSaffron/memory_profiler
  2. Rails Guides, Active Record Query Interface. https://guides.rubyonrails.org/active_record_querying.html
  3. Ruby documentation, GC module. https://docs.ruby-lang.org/en/master/GC.html