| name | spree-performance |
| description | Profile and optimize a Spree application — N+1 queries with bullet/scout, database indexing strategy for Spree's polymorphic associations, Rails fragment + Russian doll caching, ActiveStorage variant pre-generation, Sidekiq queue tuning, MeiliSearch vs Postgres FTS tradeoffs, Puma worker/thread sizing, CDN strategy for catalog pages, asset precompile time, and load testing. Use when Spree is slow, the database is hot, or you're preparing for a traffic spike (Black Friday, launch).
|
Spree Performance
Before writing code
Fetch live docs:
- Search the Spree blog at https://spreecommerce.org/blog for performance posts (Black Friday case studies, scaling guides).
- Inspect
Spree::OrderUpdater and other "hot path" service objects in the live source for known bottlenecks.
- Cross-reference Rails 7+ performance guides for caching primitives.
- For Sidekiq tuning, fetch https://github.com/sidekiq/sidekiq/wiki/Best-Practices.
- Check the v5.4 announcement (https://spreecommerce.org/announcing-spree-commerce-5-4/) for the API v3 "10x faster than v2" claim and what changed.
Conceptual Architecture
Where Spree Spends Time
Profiling typical Spree requests shows hotspots at:
- N+1 queries in catalog views (variants, prices, images)
- Order recalculation on cart updates (
OrderUpdater traverses line items, adjustments, taxes)
- Tax calculation (
Spree::TaxCalculator per-line)
- Promotion eligibility (rules evaluated per applicable promotion per order)
- Image processing (ActiveStorage variants generated on first request)
- Search (Postgres FTS or MeiliSearch query)
- Adapter overhead on heavy use cases (JSON:API in v2 was a serializer bottleneck — fixed in v3 flat-JSON)
v3 API is ~10× Faster than v2
The v5.4 v3 API rewrite cites 10× perf vs v2:
- Flat JSON serialization beats JSON:API
- Prefixed IDs lookup uses index
- Fewer DB round-trips per response
- Better Russian-doll caching support
If migrating from v2 → v3, expect significant catalog-page speedups.
Common N+1 Sources
Spree::Product.all.each { |p| puts p.master.price }
Spree::Product.includes(master: :prices).each { |p| puts p.master.price }
Spree::Product.includes(:images, :stores, master: [:prices, :stock_items])
Use bullet in dev/test:
group :development, :test do
gem 'bullet'
end
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.rails_logger = true
Bullet.add_footer = true
end
Fragment Caching
<% cache product do %>
<%= render product %>
<% end %>
Cache key auto-busts on product.updated_at. Combine with touch: true on associations for Russian-doll caching:
class Spree::Variant < ApplicationRecord
belongs_to :product, touch: true
end
Catalog Page Caching
For PLPs that don't change per user:
class CatalogController < ApplicationController
def show
expires_in 5.minutes, public: true
@products = current_store.products.active.includes(:taxons, master: :prices)
end
end
For per-user PDP (price varies):
- Cache the immutable bits as fragments
- Render user-specific bits without cache
Database Indexing
Spree ships indexes for most common access patterns. Verify your queries hit them — EXPLAIN ANALYZE in psql. Common missing indexes after schema changes:
CREATE INDEX index_spree_orders_on_user_id_and_state ON spree_orders(user_id, state);
CREATE INDEX index_spree_adjustments_on_adjustable ON spree_adjustments(adjustable_type, adjustable_id);
For multi-store, scope indexes to store_id columns.
ActiveStorage Variant Pre-Generation
class Spree::Image < ApplicationRecord
has_one_attached :attachment do |attachable|
attachable.variant :small, resize_to_limit: [120, 120], preprocessed: true
attachable.variant :large, resize_to_limit: [1200, 1200], preprocessed: true
end
end
Without preprocessed: true, the first user request generates the variant — slow.
Sidekiq Queue Strategy
:concurrency: 25
:queues:
- [critical, 5]
- [webhooks, 3]
- [mailers, 2]
- [default, 1]
Tune :concurrency against DB pool size — each Sidekiq thread takes one DB connection.
Puma Sizing
workers ENV.fetch('WEB_CONCURRENCY') { 2 }
threads_count = ENV.fetch('RAILS_MAX_THREADS') { 5 }
threads threads_count, threads_count
preload_app!
Math: workers × threads_count ≤ DB connection pool. Set pool in database.yml to threads_count.
Reporting Queries
Reports over Spree::Order can scan millions of rows. Strategies:
- Materialized views for daily/monthly aggregates
- Replica DB for reporting (avoid contention on primary)
- Pre-computed via Sidekiq jobs cached in Redis
- Pagination with cursors (
starting_after) not OFFSET
Image CDN
Front ActiveStorage with a CDN:
amazon:
service: S3
access_key_id: <%= ENV['AWS_ACCESS_KEY_ID'] %>
secret_access_key: <%= ENV['AWS_SECRET_ACCESS_KEY'] %>
bucket: my-spree-bucket
region: us-east-1
public: true
Set CloudFront / Cloudflare in front. URLs become https://cdn.example.com/....
MeiliSearch vs Postgres FTS
| Backend | Latency | Faceting | Typo tolerance |
|---|
| MeiliSearch | <50ms typical | Yes | Yes (built-in) |
| Algolia | <50ms typical | Yes | Yes |
| Postgres FTS | 100-500ms | Limited | Trigram-based, manual |
MeiliSearch wins for catalog search above ~10k products.
Order Recalc Cost
Spree::OrderUpdater runs on every line-item change. If profiling shows it dominant:
- Batch updates (
Spree::OrderMutations.batch { |order| ... } — verify API)
- Defer to a Sidekiq job if not needed synchronously
- Swap
Spree::Dependencies.order_updater_class with a leaner implementation if you don't use all features
Implementation Guidance
Profiling Setup
group :development do
gem 'bullet'
gem 'rack-mini-profiler'
gem 'flamegraph'
gem 'stackprof'
gem 'memory_profiler'
end
Hit a slow page with ?pp=help to see profiler menu.
Identifying Slow Queries
Enable pg_stat_statements:
CREATE EXTENSION pg_stat_statements;
SELECT calls, mean_exec_time, query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
Cache Key Versioning
After a Spree minor upgrade, bust caches:
Rails.application.config.cache_classes_version = 'spree_5.4.2_v1'
(Add as fragment cache version suffix.)
Black Friday Prep Playbook
- Capacity test 4 weeks before — synthetic load = 5× expected peak
- DB read replica for reporting queries
- Cache warming — pre-generate ActiveStorage variants for all live products
- CDN cache TTLs raised on catalog pages
- Sidekiq scaling test — webhook backlog handling at burst
- Database connection pool raised on primary
- Read-only mode plan in case of overload
- War room runbook — who is on call for what
- Disable expensive features during peak (e.g., live carrier rate calls — fall back to flat rate)
Common Pitfalls
Spree::Order.all in a controller — full table scan; always scope.
includes not matching what the view uses — N+1 sneaks back in.
- Fragment cache without
touch: true — stale cards on inventory change.
- Sidekiq concurrency exceeding DB pool — connection-pool errors under load.
- Image processing on web tier — blocks request thread; do in Sidekiq.
- OFFSET pagination over large tables — slow; use cursors.
- MeiliSearch index out of sync — events not firing or job failures; monitor lag.
- Ransack filters scanning unindexed columns — admin order search slow over millions of rows.
- Logging full response bodies — log volume explodes; filter.
spree:install rake task in CI on every build — slow; cache the test app.
Always profile before optimizing — assumptions about where the time goes are usually wrong. Spree's hot paths are well-known; community blog posts and Spree's own benchmarks are the best starting point.