At a glance
| Problem | One variant-count query for every product shown in the upsells dashboard |
| Scope | Checkout::UpsellsPresenter in Gumroad’s Rails application |
| Result | 50.0% lower mean request duration at 81 products; variant queries reduced from 81 to 1 |
| Delivery | Regression-tested open source change merged upstream |
The problem
Gumroad’s checkout upsells presenter tells the dashboard whether each visible product has multiple versions. It previously answered that question inside the product serialization loop:
product.alive_variants.limit(2).count > 1
Because count executes a database query, the presenter issued another variant query for every product. The dashboard therefore became increasingly expensive as a seller’s product catalog grew.
What I changed
I moved the question from individual Active Record associations into one set-based database query. The presenter now:
- loads the seller’s visible, non-archived products once;
- filters to live variants belonging to live variant categories;
- groups those variants by product ID;
- uses
HAVING COUNT(base_variants.id) > 1to return only products with multiple versions; and - converts those IDs to a set for constant-time lookups while serializing products.
This keeps the existing response shape and preserves the previous filtering behavior. The only change is where the work happens: the database determines the complete set in one query instead of Rails asking the same question separately for each product.
Measured result
I benchmarked two catalog sizes in the original contribution: four products to represent a typical seller and 81 products to show the effect on a large store.
4 products
| Metric | Before mean | After mean | Mean change | Before median | After median |
|---|---|---|---|---|---|
| Request duration | 137.34 ms | 117.80 ms | −14.2% | 135.25 ms | 122.15 ms |
| SQL duration | 18.91 ms | 12.45 ms | −34.2% | 19.38 ms | 11.81 ms |
| Allocated objects | 27,593 | 25,684 | −6.9% | 27,592 | 25,693 |
| SQL events | 26 | 23 | −11.5% | 26 | 23 |
| Duplicate query fingerprints | 8 | 5 | −37.5% | 8 | 5 |
| Variant queries | 4 | 1 | −75.0% | 4 | 1 |
81 products
| Metric | Before mean | After mean | Mean change | Before median | After median |
|---|---|---|---|---|---|
| Request duration | 198.35 ms | 99.13 ms | −50.0% | 195.06 ms | 96.29 ms |
| SQL duration | 46.42 ms | 11.16 ms | −76.0% | 45.54 ms | 11.75 ms |
| Allocated objects | 76,736 | 29,057 | −62.1% | 76,721 | 29,057 |
| SQL events | 103 | 23 | −77.7% | 103 | 23 |
| Duplicate query fingerprints | 85 | 5 | −94.1% | 85 | 5 |
| Variant queries | 81 | 1 | −98.8% | 81 | 1 |
The larger catalog shows the scaling benefit most clearly: the grouped query keeps variant-query count constant while reducing mean request time by half.
Validation
The regression spec creates products with zero, one, and multiple live versions, then subscribes to Rails’ sql.active_record notifications while the presenter builds its response. It verifies both sides of the behavior:
- each product receives the correct
has_multiple_versionsvalue; and - exactly one query joins variants to variant categories.
The test passes with the fix and fails against the previous implementation, directly demonstrating the removed N+1. The presenter spec completed with 2 examples and 0 failures, and RuboCop reported no offenses on the changed files.