Case studies / Open source

Removing per-product queries from Gumroad’s Products API

A co-authored open source Rails performance fix that batch-loaded product serialization data, cutting mean response time by 40.4% and SQL events by 77.7%.

Gumroad Products API results at a glance: 40% faster requests, 69% less SQL time, 78% fewer SQL events, 94% fewer duplicate query fingerprints, 23% fewer allocations, and all 70 per-product target queries eliminated.

At a glance

ProblemProduct pages were batched, but serialization still queried several associations once per product
ScopeCo-authored batch-loading work for GET /api/v2/products
Result40.4% lower mean request duration and 77.7% fewer SQL events for ten products
DeliveryRegression-tested open source commit merged upstream

The problem

Gumroad’s Products API fetched a page of products in one operation, but its serializer traversed associations that the controller had not preloaded. Prices, thumbnails, product files, checkout custom fields, SKUs, and variant prices could therefore trigger more database work for every product in the response.

For a benchmark response containing ten populated products, serialization produced 103 SQL events. Seventy targeted queries were per-product lookups, so the endpoint’s database work grew with the page size despite the products themselves already being loaded as a batch.

What I contributed

I profiled the endpoint and contributed the foundational batch-loading change that was preserved in the merged pull request. It:

  • expanded the products index preload tree to include live prices, thumbnails and their Active Storage attachment/blob records, ordered live product files, checkout custom fields, live SKUs, and nested variant and tier prices;
  • introduced scoped associations so the batched records matched the serializer’s existing filters;
  • updated product-file serialization to prefer the ordered preloaded collection;
  • updated variant-price calculation to reuse preloaded live SKUs and variants instead of issuing another minimum query; and
  • added controller and model regression specs that inspect SQL notifications and verify the serialized custom-field output.

The optimization changed how the API obtained its data, not the response contract. This case study is intentionally limited to the commit co-authored by @haseebeqx; the upstream pull request also contains follow-up work by other contributors.

Measured result

I benchmarked five requests after three full warmups using an OAuth-authenticated response with ten populated products.

MetricBefore meanAfter meanChange
Request duration162.36 ms96.70 ms−40.4%
SQL duration29.30 ms8.98 ms−69.4%
Allocated objects58,39644,762−23.3%
SQL events10323−77.7%
Duplicate query fingerprints885−94.3%
Target association queries917−92.3%
Per-product target queries700−100%

The deterministic query counts show the scaling improvement directly: the targeted associations were loaded per page instead of per product, and all 70 per-product target queries disappeared.

Validation

The controller regression spec creates multiple products with product-specific and global checkout fields, files, variants, and thumbnails. It verifies the API output, then asserts bounded query counts for prices, thumbnails, product files, custom fields, and Active Storage records. It also checks that variant-price serialization does not execute a per-product MIN query.

A focused model spec loads only live SKUs alongside variants, confirms deleted SKUs are excluded, and verifies that calculating the display price performs no additional base_variants query. Both focused specs passed before the contribution was rehomed and merged upstream.

View the merged Gumroad pull request →