Skip to content
AITroveRead. Build. Understand.
Make this comfortable

Rails Active Record Scope, Eager Load, and Page Cost

Last updated: 5 Oct 20268 min read
tutorial
IntermediateBy AITrove Editorial

Active Record relations defer SQL until a result is needed. That makes composition useful, but a template can silently trigger more SQL when it reads an unloaded association on every row. A permit queue must first constrain rows to the current reviewer, then choose the page width and only the related data visible on that page. Eager loading addresses repeated association reads; it does not justify loading every attachment into memory. The order must be deterministic when timestamps tie. A keyset cursor combines the last ordered values with the same scope predicate; merely returning an opaque cursor does not authorize the next page.

Working case

The first permit board asks for 47 cases and displays each district name. Its initial query is fast, but 47 lazy district reads follow. A second version calls includes(:district, :attachments) for a page whose one case has 620 scans. Query count falls while response memory and transfer size jump. A third version uses offset 47000; the database spends work locating and skipping rows before it returns the next 47. The repaired queue filters assignments in SQL, caps the parent page, preloads the district only, obtains attachment totals from a count projection or maintained counter, and orders by created_at plus ID. Each continuation keeps the reviewer predicate.

Implementation boundary

ruby
reviewer_cases = PermitCase.joins(:reviewer_assignments)
  .where(reviewer_assignments: { reviewer_id: reviewer.id })
  .includes(:district)
  .order(created_at: :desc, id: :desc)
  .limit(47)

cards = reviewer_cases.map do |permit|
  { case_id: permit.id, title: permit.title, district: permit.district.name }
end

Start with a relation constrained by reviewer assignment. Select a bounded page before rendering, and use includes or preload for the district relation actually shown. A counter cache is useful only when its update and reconciliation rules are reliable; otherwise a grouped count query for the bounded parent IDs can supply attachment totals. Use pluck for pure scalar output where model callbacks and associations are unnecessary, but remember that pluck executes immediately and no longer yields a chainable relation. For keyset continuation, apply a lexicographic predicate on created_at and ID matching the descending sort, with an index supporting the filter and order. If the product needs exact numbered pages, choose offset and count deliberately and measure their deep-page cost.

Cost and boundaries

A bounded parent page uses O(page width) case memory; preloading one district relation adds a bounded number of district objects. Eager loading a many-valued attachment relation can consume O(total children), and one unusually large permit can dominate that cost. A count query adds database work but transfers far less data than every child record. Deep offset can still scan or skip many rows to return 47; a matching keyset predicate often makes continuation cheaper, although it does not supply a cheap exact page number or stable total during concurrent inserts. Measure SQL count, rows read, bytes returned, object allocations, p95 latency, and database plans on realistic distributions rather than the empty development database.

Failure trace

Seed 47 permits with repeated created_at values and follow every cursor page without duplicate or missing IDs. Give one permit 620 attachments and prove the queue reads only its count. Temporarily remove district preload and inspect the extra association queries. Send a cursor produced for reviewer 47 while authenticated as reviewer 81; the next query must still use reviewer 81's assignment scope and cannot reveal reviewer 47's cases. Compare an offset deep in a large fixture with the matching keyset query plan. Alter the template to read an unpreloaded association and make a strict-loading test catch it. Keep a case with no district where the schema allows it and decide whether that row should be omitted or represented as missing.

Verification

  • The reviewer predicate remains in SQL for each page.
  • District reads do not create one query per permit.
  • Attachment objects are not loaded merely to display a count.

Practice drill

Build a Rails reviewer queue containing permit title, district label, attachment count, and status. Begin with an instrumented 47-row fixture, then add one attachment-heavy permit and timestamp ties. Scope by reviewer assignment and make the order created_at descending with ID as tie breaker. Preload only the district, calculate counts without hydrating attachment models, and project a response hash. Implement next-page continuation with the same SQL permission predicate. Compare number of queries and object allocations before and after the change. Record the index and query plan on the database chosen for the real service. State whether the interface needs numbered navigation or only next-page continuation.

Decision note

Constrain permission and page width in SQL, then optimize the exact relation data the response displays.

Common Mistakes

  • Fixing N+1 by eagerly loading every child association.
  • Paginating after Ruby-side filtering.
  • Using only a nonunique timestamp as cursor order.

Related lessons

Rails Request, Record, and Job Boundaries; Rails Controller Parameters and Object Permission; Rails Locking, Transaction, and Command Replay; Rails Active Job After-Commit and Outbox Recovery; Data Persistence; Laravel Eloquent Loading and Cursor Page Cost; Flask-SQLAlchemy Session and Query Lifetime.

Apply and check

Build Project: Rails permit review workflow and review Web Development: Rails request, record, and job quiz.

web-tech
web-development
Storage details