Performance Optimization and N+1 Query Prevention in PropertyWebBuilder
PropertyWebBuilder eliminates N+1 queries by combining centralized eager-loading scopes in models, consistent includes chains in controllers, and the Bullet gem for runtime detection.
PropertyWebBuilder is a multi-tenant Rails application managing large datasets of properties, listings, and users. To maintain fast response times and reasonable database load, the codebase implements a pro-active eager-loading strategy that prevents N+1 queries at the architectural level. This guide examines the specific patterns implemented in the etewiah/property_web_builder repository to optimize database performance.
Centralized Eager-Loading Scopes in Models
The application centralizes reusable eager-loading logic within model concerns to ensure consistency across controllers. The primary implementation lives in app/models/concerns/listed_property/searchable.rb, which provides scopes that bundle required includes calls.
The with_eager_loading scope pre-loads the associations most commonly accessed in property listings:
# app/models/concerns/listed_property/searchable.rb
scope :with_eager_loading, -> {
includes(:website, :sale_listing, :rental_listing,
prop_photos: { image_attachment: :blob })
}
Using includes allows ActiveRecord to load the associated records in a single SQL query (or two for has_many :through relationships), preventing individual queries when the view accesses area, price, or photo data. For lighter-weight widgets that require only photo references without the heavy ActiveStorage blob data, the concern also provides a with_photos_only variant.
Extending Scopes for Custom Associations
You can extend the centralized pattern by adding new scopes that target specific associations. For example, to eager-load agent data alongside properties:
# app/models/concerns/listed_property/searchable.rb
scope :with_agent, -> { includes(:agent) }
Callers can then chain this scope with existing ones to fetch all required data in two SQL statements:
properties = ListedProperty::Searchable
.properties_search(params)
.with_eager_loading
.with_agent
Controller-Level N+1 Prevention
Every controller that renders collections or detail pages consistently applies eager-loading to the relations the view requires. This pattern appears throughout the tenant_admin and site_admin namespaces, ensuring views can iterate over associations without firing extra queries per record.
Key implementations include:
- TenantAdmin::SubdomainsController (
set_subdomain): Usesincludes(website: { user_memberships: :user })to load Subdomain → Website → User memberships → User in a single query chain. - TenantAdmin::SubscriptionsController (
index): CallsPwb::Subscription.includes(:website, :plan)to pre-load subscription plan data. - SiteAdmin::UsersController (
index): Appliescurrent_website.users.includes(:user_memberships)for admin user listings. - SitemapsController (
show): Usesincludes(prop_photos: { image_attachment: :blob })to generate sitemap XML without N+1 penalties on ActiveStorage attachments.
The app/controllers/concerns/site_admin_indexable.rb helper further enforces this pattern by dynamically injecting includes based on controller-declared indexable_includes arrays, standardizing the approach across resourceful controllers.
Limiting Result Sets for Bulk Operations
When loading large datasets for dashboard indices, controllers combine eager-loading with strict record limits to control memory consumption. The TenantAdmin::PagesController#index demonstrates this defensive pattern:
# app/controllers/tenant_admin/pages_controller.rb
@pages = Pwb::Page.unscoped
.includes(:website)
.order(created_at: :desc)
.limit(100) # ← avoid full‑table scan
This same pattern appears in TenantAdmin::PropsController and TenantAdmin::ContentsController, ensuring that bulk admin views remain responsive regardless of table size.
Practical Controller Implementation
When implementing a new index action, follow the established pattern from TenantAdmin::AgentsController:
# app/controllers/tenant_admin/agents_controller.rb
class TenantAdmin::AgentsController < TenantAdminController
def index
@agents = Pwb::Agent
.includes(:website, :user) # eager‑load
.order(created_at: :desc)
.limit(50) # limit result set
end
end
The view can safely call agent.website.name and agent.user.email without triggering additional database queries.
Runtime Detection with Bullet
During development, PropertyWebBuilder integrates the Bullet gem to surface any missed N+1 queries immediately. Configured in config/environments/development.rb, Bullet raises alerts when views iterate over associations that have not been eager-loaded:
# config/environments/development.rb
Bullet.enable = ENV.fetch("BULLET_ENABLED", "true") == "true"
Bullet.alert = ENV.fetch("BULLET_ALERT", "false") == "true"
Bullet.bullet_logger = ENV.fetch("BULLET_LOGGER", "true") == "true"
Bullet.console = ENV.fetch("BULLET_CONSOLE", "true") == "true"
Bullet.rails_logger = ENV.fetch("BULLET_RAILS_LOGGER", "true") == "true"
Bullet.add_footer = ENV.fetch("BULLET_FOOTER", "true") == "true"
Bullet.add_safelist type: :unused_eager_loading,
class_name: "Pwb::SiteAdmin::UserSearch"
The configuration logs warnings to the browser console and Rails logger, giving developers instant feedback. The safelist prevents false positives for known acceptable patterns, such as unused eager loading in admin search interfaces. Comments in app/helpers/pwb/component_helper.rb further document why specific fields are eager-loaded to satisfy Bullet requirements.
Composable Search with Pre-loaded Relations
The ListedProperty::Searchable module exposes a properties_search class method that returns an ActiveRecord::Relation. Because the default scopes (all.visible.for_sale or all.visible.for_rent) return relation objects, you can chain with_eager_loading without executing additional queries:
# Example usage in a controller or service
search = ListedProperty::Searchable.properties_search(**params)
search = search.with_eager_loading # load website, listings, photos in one go
This composable API keeps search logic decoupled from loading logic while guaranteeing that the final result set arrives with all necessary associations pre-loaded.
Summary
- Centralize eager-loading scopes in model concerns like
ListedProperty::Searchableto provide reusableincludesbundles for common use cases. - Apply
includesconsistently in every controller action that renders collections, nesting associations as needed (e.g.,includes(website: { user_memberships: :user })). - Limit result sets using
.limit(n)on bulk index actions to prevent memory bloat on large tables. - Enable Bullet in development to catch N+1 queries immediately via browser alerts and Rails logs, adding safelists only for verified acceptable patterns.
- Chain scopes composably when building search interfaces, allowing
properties_searchresults to leveragewith_eager_loadingwithout query penalties.
Frequently Asked Questions
What causes N+1 queries in ActiveRecord?
N+1 queries occur when application code iterates over a collection of records and accesses an associated record that was not loaded in the initial query, forcing the database to execute an additional query for each iteration. PropertyWebBuilder prevents this by declaring includes in model scopes and controller queries before collections reach the view, ensuring associations are pre-loaded.
How does the Bullet gem configuration work in PropertyWebBuilder?
Bullet monitors query patterns during development and raises alerts when it detects N+1 queries or unused eager loading. In config/environments/development.rb, Bullet is enabled with environment variable toggles and configured to log to the Rails logger and browser console. The configuration includes a safelist for Pwb::SiteAdmin::UserSearch to suppress false positives in admin search interfaces where eager loading might appear unused due to conditional view logic.
When should I use includes versus joins for query optimization?
Use includes when you need to access associated data in views or subsequent operations, as it triggers eager loading that prevents N+1 queries. Use joins when you only need to filter by associated data without instantiating the associated objects. PropertyWebBuilder consistently uses includes in collection controllers—evident in SitemapsController and SubscriptionsController—to ensure views can safely traverse associations without database penalties.
How do I extend eager loading for custom associations in this codebase?
Define a new scope in the relevant model concern, such as adding scope :with_agent, -> { includes(:agent) } to ListedProperty::Searchable. In your controller, chain this scope alongside existing ones like with_eager_loading. Follow the pattern in TenantAdmin::AgentsController by applying .includes(:website, :user) directly in the index action, and always combine with .limit(n) when rendering large collections.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →