Skip to content

A checkout that stopped losing sales to a 6-second load

Profiling found the real bottleneck behind a slow checkout (an N+1 query and an oversized bundle) and tuned both, with before-and-after metrics locked in as a baseline against future regressions.

Route budgets

Every route against a two-second time-to-interactive budget · field data from real sessions

Last 7 days Thu, 09/17/2026MO

Checkout · time to interactive

1.8s

Was 6.1s · budget 2.0s · within

Checkout query time · p90

−82%

Server timing, before tuning → now

Cart abandonment

−19%

Six weeks after against six before

Checkout queries · 10-item cart

19

Pinned by a test · was 68 per-item

All routes 2.0s budget Within budget

11 routes · p75 of real sessions
RouteViewsTime to interactiveLargest paintBlocking timeQueriesStatus
/Home412.8k9 Within
/searchSearch results236.1k6 Within
/aisle/[slug]Aisle198.4k11 Within
/product/[slug]Product301.7k14 Within
/cartCart88.2k19 Within
/checkoutCheckout61.5k1.8s · was 6.1s19Baseline Within
/order/[id]/confirmedOrder confirmed40.9k12 Within
/accountAccount52.3k7 Within
/account/ordersPast orders34.6k10 Within
/recipes/[slug]Recipe73.0k8 Within
/helpHelp18.7k3 Within

/checkout · time to interactive, field

Scale 0–7s
Before tuning6.1s
Now1.8s

Why three shipped fixes didn't move it

Profiling session
AssumedImage weight
ServerN+1 per cart item
ClientBlocking bundle

How the work was scoped

Industry
E-commerce & Retail
Duration
5 weeks
Cooperation model
Fixed price
Services
Performance profilingQuery & bundle optimizationBaseline monitoring
Integrations
ShopifyStripeKlaviyoShipStation
Technologies
Chrome DevToolsLighthouseWeb VitalsDatabase query profilerWebpack bundle analyzerSynthetic monitoring
Team
1 Project lead1 Data engineer1 Analytics engineer1 Backend engineer

Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.

What went wrong, and when

01
  1. 01

    Nobody had profiled the actual bottleneck. The team assumed it was image weight, but the real cost was an N+1 query firing per cart item and a JavaScript bundle that had grown unchecked for a year.

    There were two unrelated causes of roughly equal size. On the server, the cart page issued one product lookup per line item: invisible on a two-item test cart, ruinous on a real one. On the client, a JavaScript bundle had grown for a year without anyone looking, and it blocked render. Fixing either alone would have moved the number by about a third and looked like a failure.

    We profiled Core Web Vitals in the browser and query time on the server, found the N+1 pattern and the render-blocking bundle, fixed both, and recorded the improved metrics as a baseline so future regressions get caught early.

Process

Phase by phase

  1. Phase 1: Measure

    Against real traffic

    Profiled Core Web Vitals in the browser and query time on the server using production traffic, not a synthetic ideal.

    • Vitals baseline
    • Query profile
  2. Phase 2: Isolate

    One query per cart item

    Traced the slow checkout to an N+1 query pattern on the checkout path and an oversized render-blocking bundle.

    • Root-cause analysis
    • Bundle report
  3. Phase 3: Fix

    Query and bundle

    Fixed the query pattern and split the bundle so the checkout path stopped loading code it didn't need.

    • Query fix
    • Split bundle
  4. Phase 4: Lock it in

    So it stays fixed

    Recorded the improved metrics as a monitored baseline with alerting on regression.

    • Metric baseline
    • Regression alerts
Bundle · /checkout

The checkout route's JavaScript, module by module · before the split and now

BeforeSide by sideNow Thu, 09/17/2026MO

Before · one bundle, grown for a year

All loaded before render
react + react-dom
checkout-form
store-locator-map
reviews-editor
recipe-carousel
nutrition-charts
payment-sdk
date-locales (all)
analytics + chat
address-lookup
Checkout uses it Checkout never needed itTiles drawn to shape

Now · split, blocking work deferred

Checkout chunk + deferred
react + react-dom
checkout-form
payment-sdk
analytics + chatafter interactive
address-lookup
No longer on /checkout5 packs moved to the routes that use them
Checkout chunk Loaded after interactiveTiles drawn to shape

Module by module

10 modules · 5 never needed on checkout
ModuleUsed byBeforeNow
react + react-domEvery routeInitial · blocks renderCheckout chunk
checkout-form/checkoutInitial · blocks renderCheckout chunk
store-locator-map/storesNever used hereOwn route only
reviews-editor/product/[slug]Never used hereOwn route only
recipe-carousel/recipes/[slug]Never used hereOwn route only
nutrition-charts/product/[slug]Never used hereOwn route only
payment-sdk/checkoutInitial · blocks renderCheckout chunk
date-locales (all)One locale usedNever used hereOwn route only
analytics + chatEvery routeInitial · blocks renderAfter interactive
address-lookup/checkoutInitial · blocks renderCheckout chunk
On screen

The checkout route's JavaScript before and after the split, module by module: the packs it never needed moved to their own routes and the render-blocking work deferred.

Outcome

The numbers, before and after

6.1s → 1.8s

Time to interactive

−82%

Checkout query time

−19%

Cart abandonment

Time to interactive is field data from real sessions at comparable traffic, before and after; it isn't a Lighthouse score. Query time is the checkout path's server timing at the ninetieth percentile. Cart abandonment compares the six weeks after with the six before, short enough that seasonality is a caveat, not a correction.

Client name withheld under NDA. Figures are approximate, drawn from the engagement’s own reporting.

Introduction

The engagement

The checkout page was taking upward of six seconds to become interactive during peak traffic, and the team had already guessed at three different fixes that didn't move the number.

An online retailer whose checkout took upward of six seconds to become interactive at peak, and whose team had already shipped three fixes aimed at image weight without moving the number. The engagement ran five weeks and began with one condition: nothing would change until both ends had been profiled, because three unmeasured attempts had already been spent.

Performance Engineering

How it was handled

Profiled frontend Core Web Vitals and backend query time against real traffic

Profiling was made a precondition, because three unmeasured fixes had already shipped against the wrong cause.

Isolated an N+1 query firing once per cart item on the checkout path

The N+1 was found in the query profiler, then collapsed into one batched query with the relations loaded eagerly.

Fixed the query pattern and split the oversized JavaScript bundle

The bundle was split and the render-blocking work deferred: the half of the six seconds the server fix could never have reached.

Recorded before-and-after metrics as a regression baseline

Before-and-after metrics were committed as a baseline with synthetic monitoring running the real checkout journey against it on a schedule.

Profiled, not guessed

Frontend vitals and backend query time measured against real traffic before anything was changed.

Nothing was changed until both ends had been measured under real traffic. Web Vitals came from actual sessions instead of a lab run on a fast laptop, and server timing was profiled per request, so a slow page could be pinned on render, network or the database instead of argued about. Both profiles contradicted the team's expectations, which is exactly why the step exists.

What shipped
  • Field Web Vitals from real sessions, never a lab run
  • Per-request server timing to separate render from query cost
  • Both profiles contradicted the assumed cause
Profiling session · /checkout

Field Web Vitals from real sessions beside per-request server timing · captured before anything was changed

Export session Thu, 09/17/2026MO

Field Web Vitals · /checkout

Real sessions · peak traffic
6.1stime to interactiveOver the 2.0s budget
0–7s
Lab run

Fast laptop, two-item test cart

Neither fault showed

Field sessions

Real shoppers and real carts

Both faults, at peak

Server timing · one request

POST /checkout/review
Server-Timing: db;desc="138 queries",
app;desc="render", edge;desc="network"
Database138 queries, 120 of them from one loop
NetworkDocument, then the checkout bundle
RenderWaiting on the bundle to parse and run

One checkout load, both ends

Interactive at 6.1s
Span0s2.0s budget6.1s
Document request
Session, cart, customer18 queries
Cart-line loop120 queries · 24 itemsN+1
Server render
HTML to browser
checkout.bundle.jsdownload
Parse & executeblocks renderBlocking
Hydrate checkout form
InteractiveTTI 6.1s
database network renderSpans drawn to shape · one request at peak

Assumed, then found

Before any change
Assumed

Image weight

Three fixes shipped against it. The number didn't move.

Found · server

N+1 query per cart item

One product lookup for every line in the cart.

Found · client

Render-blocking bundle

Grown unchecked for a year, parsed before the form could run.

Two causes of similar size

Either fix alone moves the number by about a third. Both are needed.

On screen

The profiling session before any change: field Web Vitals from real sessions beside per-request server timing, one checkout load attributed to database, network and render, and the two causes found in place of the assumed one.

Query profile · POST /checkout/review

One checkout request opened up · cart of 24 items · captured in the profiling session

Compare with now Thu, 09/17/2026MO

Queries · this request

138

From the cart-line loop

1205 × 24 items

Fixed per request

18

After batching · any cart

1918 + 1 batched

Checkout query time · p90

−82%

Before · per-item lookup

138 queries in request order
checkout/load-cart.ts:41 for (const line of cart.lines)
18 fixed queriessessions, customers, carts, cart_lines, addresses, delivery_zones, delivery_slots, promotions, promo_redemptions, loyalty_accounts, gift_cards, payment_methods, tax_rates, substitution_prefs, bag_fees, store_settings, feature_flags, order_drafts#1–#18
cart.lines → getProduct(line.sku)24 iterations · 120 queriesN+1
Item 1Stoneground oats, 1 kgOKM-10482
#19SELECT * FROM products WHERE id = $1lookup
#20SELECT * FROM product_variants WHERE product_id = $1lazy · product_variants
#21SELECT * FROM stock_levels WHERE variant_id = $1lazy · stock_levels
#22SELECT * FROM price_rules WHERE product_id = $1lazy · price_rules
#23SELECT * FROM product_images WHERE product_id = $1 LIMIT 1lazy · product_images
Item 2Cold-brew coffee concentrateOKM-22019
#24SELECT * FROM products WHERE id = $1lookup
#25SELECT * FROM product_variants WHERE product_id = $1lazy · product_variants
#26SELECT * FROM stock_levels WHERE variant_id = $1lazy · stock_levels
#27SELECT * FROM price_rules WHERE product_id = $1lazy · price_rules
#28SELECT * FROM product_images WHERE product_id = $1 LIMIT 1lazy · product_images
Items 3–24the same five queries again, 22 more times#29–#138

Every query in this request, in order fixed cart-line loop

Now · one batched query

checkout/load-cart.ts
-for (const line of cart.lines) {
- line.product = await getProduct(line.sku)
-}
+const skus = cart.lines.map((l) => l.sku)
+const products = await db.products.findMany({
+ where: { sku: { in: skus } },
+ include: {
+ variants: true, stock: true,
+ prices: true, images: true,
+ },
+})
1 query for every line · relations loaded eagerly19 total

Query-count test

load-cart.queries.test.ts
it("ten-item cart loads in 19 queries", async () => {
const cart = await seedCart({ items: 10 })
const n = await countQueries(loadCheckout, cart)
// one batched lookup, whatever the cart holds
expect(n).toBe(19)
})
Passing on main10 items → 19 queries
If a field is added back inside the loopTest fails
On screen

One checkout request opened up: 138 queries, the N+1 firing once per cart item, the batched query with eager relations that replaced it, and the test that pins the query count for a ten-item cart.

The actual bottleneck

An N+1 query firing once per cart item, found in the profiler instead of assumed.

The query profiler showed the cart page issuing one product lookup per line item: invisible on a two-item test cart, ruinous on a real one. It was collapsed into a single batched query with the relations loaded eagerly, and a test now asserts the query count for a ten-item cart, so the pattern can't creep back the next time someone adds a field to the loop.

What shipped
  • N+1 caught in the profiler, not deduced from the code
  • Collapsed into one batched query with eager relations
  • A test asserts query count for a ten-item cart
Checkout baseline

Before and after committed as a baseline · the real checkout journey run on a schedule against it

Run journey now Thu, 09/17/2026MO

Committed baseline

perf/baselines/checkout.json
a41c9e2Record checkout before/after as the baselineMaya Okonkwo · main · reviewed by Theo BrandtOn main
{
"route": "/checkout",
"budget": { "tti": "2.0s" },
"tti": {
"before": "6.1s",
"after": "1.8s",
"source": "field, real sessions"
},
"queryTimeP90": { "change": "-82%" },
"queries": { "tenItemCart": 19 },
"alert": {
"on": "percentage regression",
"against": "this baseline"
}
}

What catches a regression

Against what was committed
CheckCompared withWhen it slips
Synthetic checkout runsThe committed baseline% regression alert
Queries · 10-item cartExactly 19, in the test suiteTest fails

Alert template #checkout-speed

Synthetic checkout regressed against baseline a41c9e2

Shows the run, the step that slowed, and the percentage over the committed figure.

Synthetic checkout · the real journey

Scheduled · every 20 minutes
Home/
Add 10 items/aisle/*
Cart/cart
Checkout/checkout
Review…/review
Same journey every runReal checkout pathCompared with baseline
Baseline bandToday · 36 runs

Recent runs

Checkout journey · production
RunTime to interactive vs baselineQueriesResult
2:40 PM19Within baseline
2:20 PM19Within baseline
2:00 PM19Within baseline
1:40 PM19Within baseline
1:20 PM19Within baseline
1:00 PM19Within baseline
12:40 PM19Within baseline
12:20 PM19Within baseline
12:00 PM19Within baseline

Committed baseline · a percentage regression past it alerts the checkout team

A regression baseline

On screen

The before-and-after figures committed as a baseline, the real checkout journey run on a schedule against it, and what alerts on a percentage regression.

Before-and-after metrics recorded, so a future regression gets caught by the pipeline before a customer finds it.

The before-and-after numbers were committed as a baseline, and synthetic monitoring now runs the same checkout journey on a schedule and alerts on a percentage regression against it. Performance stops being something someone notices during a bad week and becomes something the pipeline reports. That's the only version of this work that survives six months of feature delivery.

What shipped
  • Before and after committed as a baseline, not a slide
  • Synthetic runs of the real checkout journey on a schedule
  • Alerts on percentage regression against the recorded baseline

Working inside their operation

  1. 01

    A cross-functional team of 4 worked on a fixed price basis over 5 weeks, covering Performance profiling, Query & bundle optimization, Baseline monitoring. We shipped in two-week increments, each one releasable and reviewed with the client before it merged. Decisions were recorded as they were made, so the reasoning survived the people who made it.

    Five weeks, fixed price, with measurement as a precondition: nothing could change until both ends had been profiled, because three unmeasured fixes had already been spent. The team sat in on the profiling session, and that's what made the result persuasive. They watched their own assumption fail with their own eyes.

What changed in the runbook

  • The six seconds were two unrelated causes; fixing either alone would have looked like failure.

    Two causes of similar size is why the previous attempts failed: each candidate fix was tested alone, moved the number by a third, and was judged not to be the problem.

  • Profiling against real traffic found what a synthetic test had been missing for months.

    A synthetic test on a fast connection with a two-item cart never reproduced either fault. The conditions that produced them are the ones real traffic supplies.

  • Without the baseline the fix would have quietly eroded. The monitoring is the durable part.

    Performance work erodes silently, so the alert against a committed baseline is what survives six months of feature delivery. The fixes alone wouldn't have.

One loop, three carts

A two-item test cart hid what a real cart paid for

The same checkout request at three cart sizes, with the per-item lookup it used to make and the batched query it makes now. Below it, why neither fix on its own looked like the answer. Switch tabs, or use the arrow keys once one is focused.

Queries per checkout request, by cart size

The request opened up in the profiler: 138 queries, 120 of them the same five lookups repeated once per cart item. Collapsed into one batched query with the relations loaded eagerly, it's nineteen however full the cart is.

Per-item lookup (before)18 + 5 × items

138queries fired

18 fixed, then a lookup and four lazy relations for each of 24 items: 120 from the loop.

Batched query (now)18 + 1

19queries fired

18 fixed, then one query for all 24 lines with the relations loaded eagerly.

Fixed per request Once per cart item One batched queryDifference: 119 queries

Time to interactive, one fix or both

Two causes of similar size, fixed together: time to interactive from 6.1s to 1.8s, inside the 2.0s budget. Tested one at a time, each fix had looked like it wasn't the problem.

Before tuning6.1s
Both1.8s · within

2.0s budget · scale 0–7s · one-fix bars drawn at “about a third”, not measured

Architecture

From a real shopper's session to the alert that keeps checkout fast

Nothing was changed until both ends had been measured, and nothing measured was left as a one-off. The same path that found the two causes now watches for either one coming back.

  1. 01 · Measure
    Field vitals + server timingWeb Vitals from actual sessions, never a lab run on a fast laptop; server timing per request, so time is attributed to render, network or database.
  2. 02 · Isolate
    Query profiler + bundle analyzerThe N+1 was caught in the profiler, firing once per cart item. Nobody had to deduce it from the code.
  3. 03 · Fix
    Batch, split, deferOne batched query with relations loaded eagerly; the bundle split and render-blocking work deferred. Both, because either alone moved about a third.
  4. 04 · Record
    Committed baselineBefore and after committed to the repository as a baseline, with a test asserting the query count for a ten-item cart.
  5. 05 · Guard
    Scheduled synthetic checkoutThe real checkout journey runs on a schedule and alerts on a percentage regression against the baseline.

So checkout stays fast

Measured changes & regression guards

Profiled before anything changed

Nothing was changed until both ends had been measured under real traffic: Web Vitals from actual sessions and server timing per request. Three unmeasured fixes had already been shipped against the wrong cause.

The N+1 can't creep back

A test asserts the query count for a ten-item cart, so the next time someone adds a field to the cart-line loop, the per-item pattern fails the test instead of reaching shoppers.

Regression caught, not discovered

The before-and-after figures are a committed baseline. Synthetic monitoring runs the real checkout journey on a schedule and alerts on a percentage regression against it.

Is your checkout slow for a reason nobody has measured yet? Scope your build in three minutes.

Scope your build
Have a project?

Let's talk

Running a large platform, shaping a first MVP, or getting a product ready for a funding round? Tell us where you are. We'll shape the process around it, and stay with you after launch.