Case studies / Open source

Removing an N+1 query from Gumroad’s upsells dashboard

An open source Rails performance fix that halved mean request time for a large catalog by replacing per-product variant counts with one grouped query.

Gumroad upsells dashboard benchmark for 81 products: 50% lower mean request duration, 76% less SQL time, 78% fewer SQL events, 94% fewer duplicate query fingerprints, 62% fewer allocations, and variant queries reduced from 81 to 1.

At a glance

ProblemOne variant-count query for every product shown in the upsells dashboard
ScopeCheckout::UpsellsPresenter in Gumroad’s Rails application
Result50.0% lower mean request duration at 81 products; variant queries reduced from 81 to 1
DeliveryRegression-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) > 1 to 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

MetricBefore meanAfter meanMean changeBefore medianAfter median
Request duration137.34 ms117.80 ms−14.2%135.25 ms122.15 ms
SQL duration18.91 ms12.45 ms−34.2%19.38 ms11.81 ms
Allocated objects27,59325,684−6.9%27,59225,693
SQL events2623−11.5%2623
Duplicate query fingerprints85−37.5%85
Variant queries41−75.0%41

81 products

MetricBefore meanAfter meanMean changeBefore medianAfter median
Request duration198.35 ms99.13 ms−50.0%195.06 ms96.29 ms
SQL duration46.42 ms11.16 ms−76.0%45.54 ms11.75 ms
Allocated objects76,73629,057−62.1%76,72129,057
SQL events10323−77.7%10323
Duplicate query fingerprints855−94.1%855
Variant queries811−98.8%811

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_versions value; 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.

View the merged Gumroad pull request →