Agent Skill · Spree Commerce

spree-performance

Use when the user is investigating or improving Spree performance — slow product listings, slow cart updates, search latency, image processing bottlenecks, Sidekiq queue tuning, N+1 in admin pages, cache invalidation strategies. Common phrasings include "slow PDP", "slow cart", "N+1 queries in Spree", "Sidekiq queue backlog", "search slow", "Meilisearch tuning", "image processing slow", "Spree cache". Provides the Spree-specific performance hotspots and the tools to address them.

Provider: Spree Commerce Path in repo: skills/spree-performance/SKILL.md

Skill body

Spree Performance

Most Spree performance work has more leverage than generic Rails tuning because the bottleneck is usually in one of a few known hotspots. This skill covers those.

The biggest leverage areas

In rough order of impact for typical Spree stores:

  1. Cart pipeline cost on every cart change. Spree::Cart::Recalculate is the most-run service in the app — every line-item add, remove, and quantity change fires it. A slow recalculate makes the storefront feel sluggish.
  2. Catalog rendering N+1s. Product listing pages load Products, then prices, then images, then variants, then categories — easy to hit dozens of queries per product.
  3. Search provider latency. Database search degrades past ~10K products. Meilisearch’s network round-trip + result deserialization adds up if not bounded.
  4. Image processing. Generating image variants is slow and CPU-bound — variants are pre-generated in background transform jobs at upload time, and bulk uploads can flood the queue.
  5. Sidekiq queue backlog. Per-queue weights matter — image processing flooding the default queue blocks event subscribers from firing in time.
  6. Admin product table. The N+1 problem with 100+ products and all-columns-visible is real.

The cart pipeline

Every cart change runs Spree.cart_recalculate_service (default: Spree::Cart::Recalculate). The chain reads line items, prices, adjustments, shipments, promotions, computes totals, and writes the order back.

Common cart-pipeline N+1s

Each line item lazily loads its variant, then the variant’s price, then the variant’s images for the cart UI. Eager-load before iterating:

order.line_items.includes(variant: [:prices, :images, product: :categories]).each do |li|
  # ...
end

In a custom recalculate step, prefer batch operations over per-item loops. If you must loop, eager-load the associations the loop touches.

Sidekiq for slow recalculate work

If you have a recalculate step that’s slow (external service call, complex computation), make it async via Sidekiq instead of inline. The customer doesn’t need to wait for an analytics push; fire-and-forget via a subscriber on order.updated (see the spree-events-webhooks skill).

Profiling the recalculate

Sample any cart in the Rails console:

order = Spree::Order.find(123)
ActiveRecord::Base.logger.level = Logger::DEBUG
Spree::Cart::Recalculate.call(order: order, line_item: order.line_items.first)

Read the query log. Anything above ~50 queries for a 5-item cart is high. Anything that issues per-line-item queries is fixable with eager loading.

Catalog rendering

The classic Spree catalog page (PLP) hits N+1s by default. Spree includes ar_lazy_preload to mitigate, but only for paths that use it.

preload_associations_lazily

preload_associations_lazily (from the ar_lazy_preload gem) is available on any relation — it auto-preloads whichever associations the iteration touches, with no per-model list. Spree’s own controllers chain it on collections (see Spree::Api::V3::ResourceController#collection). Use it on custom catalog queries:

@products = Spree::Product.for_store(current_store)
                          .active(Spree::Current.currency)
                          .includes(
                            primary_media: [attachment_attachment: :blob],
                            master: [:prices, stock_items: [:stock_location, :active_stock_reservations]],
                            variants: [:prices, stock_items: [:stock_location, :active_stock_reservations]]
                          )
                          .preload_associations_lazily
@pagy, @products = pagy(@products, page: params[:page], limit: params[:per_page])

Pagination is Pagy (Spree’s only pagination dependency) — the pagy(...) call mirrors Spree::Api::V3::ResourceController#collection. There is no Kaminari-style .page scope on Spree models.

For the most common path (default-variant-only listing), the API’s ProductsController#scope already does the right thing. If you’re building a custom catalog endpoint, copy that pattern.

cache_key_with_version

Every Spree model that’s Spree.base_class-derived has a cache_key_with_version instance method (from ActiveRecord) — it folds in the model’s updated_at. Use it for HTTP caching and fragment caching:

def show
  product = scope.find_by_prefix_id!(params[:id])
  fresh_when(etag: product.cache_key_with_version, last_modified: product.updated_at)
  # render serializer
end
<% cache [product.cache_key_with_version, 'pdp'] do %>
  <%= render 'pdp', product: product %>
<% end %>

product.touch (or touching any has_many child that the model belongs_to :product, touch: true on) bumps the version and invalidates the cache.

Search provider performance

Database provider (default)

Fine for catalogs < 10K products. Past that, text search slows down: the Database provider runs Spree::Variant.product_name_or_sku_cont, whose predicate is LOWER(spree_products.name) ILIKE '%q%' OR LOWER(spree_variants.sku) ILIKE '%q%' through a variants→products join (when translations are enabled, the product translations table replaces spree_products.name).

If you must stay on Database:

Meilisearch provider

The right choice for medium-to-large catalogs.

Common Meilisearch performance issues:

Image processing

Spree uses ActiveStorage and pre-generates product image variants at upload time. Spree::Asset declares a named ActiveStorage variant per entry in Spree::Config.product_image_variant_sizes (defaults: mini 128, small 256, medium 400, large 720, xlarge 2000, og_image 1200×630) as webp resize_to_fill with preprocessed: true, so ActiveStorage enqueues transform jobs in the background when the image is attached — no first-request processing on the web tier. The Store API serves exactly these named variants (mini_url, small_url, …).

Performance levers

Don’t bother pre-warming variants from a subscriber — it duplicates the built-in behavior, and ad-hoc resize_to_limit variants have a different variation digest than the named webp variants, so the storefront never serves them. A pre-warming job only makes sense for custom, non-default variant transformations your own code requests.

Use a CDN

ActiveStorage serves images via Rails by default. For production, route via CloudFront / Cloudflare with a long cache TTL. The Spree image URL helpers are CDN-friendly.

Sidekiq queue configuration

Spree organizes background work into named queues exposed via Spree.queues. By default every queue is mapped to :default, but the names are distinct so you can route them to dedicated queues in production:

# backend/config/initializers/spree.rb
Spree.queues.payment_webhooks = :payment_webhooks
Spree.queues.events           = :events
Spree.queues.webhooks         = :webhooks
Spree.queues.images           = :images
Spree.queues.search           = :search
Spree.queues.products         = :catalog
Spree.queues.variants         = :catalog
Spree.queues.exports          = :reports
Spree.queues.imports          = :imports

Then run Sidekiq with explicit queue weights:

bundle exec sidekiq -q payment_webhooks,5 -q events,4 -q default,3 -q search,2 -q catalog,2 -q images,1

Why weights matter: payment webhooks must process fast (customer is waiting); image processing can lag. Without weights, image jobs flood and delay payment events.

The full queue list lives in Spree.queues in spree_core/lib/spree/core.rb of the installed gem. Available: default, events, exports, images, imports, products, reports, variants, taxons, stock_location_stock_items, coupon_codes, themes, addresses, gift_cards, webhooks, payment_webhooks, api_keys, search, stock_reservations.

Admin product table N+1

The Rails admin preloads associations in the controller, not the table registry. The base Spree::Admin::ResourceController chains .includes(collection_includes) onto the scope and applies preload_associations_lazily (the ar_lazy_preload gem) to the collection; Spree::Admin::ProductsController#collection_includes supplies the products-table preloads (media attachments, stock items, master/variant prices) that ar_lazy_preload can’t pick up automatically. If a custom column triggers per-row queries, override collection_includes in a controller decorator to add the association. Spree.admin.tables.products.add only defines the column (label, type, sortable, filterable, etc.) — it accepts no preload: option, and passing one raises ActiveModel::UnknownAttributeError.

Caching patterns

Russian-doll fragment caching

For the storefront, cache fragments keyed by the model’s cache_key_with_version:

<% cache [product.cache_key_with_version, 'pdp', 'v1'] do %>
  <%= render 'pdp', product: product %>
<% end %>

Updates to the product (or any touch:-linked association) automatically bust the cache.

Rails.cache for expensive computations

For per-store computed values (active promo banner, configured currencies, available payment methods):

Rails.cache.fetch(['store', current_store.cache_key_with_version, 'banner'], expires_in: 5.minutes) do
  ActiveBannerService.call(current_store)
end

Don’t cache anything tied to the customer (cart, account) — it varies per session and pollutes the cache.

HTTP caching on the Store API

Only the Store API catalog controllers (products, categories, countries, currencies, markets, locales, policies) opt into Spree::Api::V3::HttpCaching — the base v3 ResourceController ships no-op caching hooks. For guest (unauthenticated) requests it sets a public Cache-Control (5-minute TTL by default): show actions use Rails stale? on the record (ETag from cache_key_with_version, Last-Modified from updated_at), while index actions get a digest ETag built from the collection’s latest updated_at, count, and query params. Authenticated requests are sent Cache-Control: private, no-store, so CDNs (Cloudflare, Fastly, CloudFront) can only cache guest /api/v3/store/products-style traffic with conditional revalidation; guest responses Vary on Accept, x-spree-currency, and x-spree-locale.

Profiling tools

Where to read further