The 640 millisecond checkout
How we took the slowest step of a grocery checkout from 2.8 seconds to 640 ms, mostly by deleting things.
Pasarloka sells groceries for same-day delivery in the Klang Valley and Penang. When I joined the checkout squad, the slowest step of the checkout, the one between tapping Checkout and seeing a total you could pay, took 2.8 seconds at the 95th percentile. On a fast laptop on office wifi it felt fine. On the phones our customers actually used, it did not.
Four months later the same step took 640 milliseconds, and checkout completion was up 11.4%. This is how, in the order we did it. Most of the gains came from removing work, not from making work faster.
Measure on the right phone
The first week went to measuring. Our dashboards showed server response times, which were fine. They did not show what the customer waited for: the network round trips, the JavaScript parse time on a mid-range phone, and the sequence of calls the app made one after another.
We bought four phones that matched our most common devices, each around RM 600 to RM 900, and put them on a shelf with a cheap prepaid SIM each. Every pull request that touched checkout got a recorded run on those phones, on mobile data, from the stairwell of the office, which had the worst signal in the building. That stairwell became our most honest test environment.
- Median phone: 4 cores, 4 GB of memory, about four years old.
- Network: 4G with 80 to 250 ms of round trip time, worse in lifts and car parks.
- Budget we set: payable screen in under 1 second at p95, measured on the device.
Nine calls in a row
The trace showed the real problem quickly. To show a total, the app called nine services one after another: cart, user, address, store, slots, stock, pricing, promotions, fees. Each call was quick, around 120 to 200 ms, but each one waited for the previous to finish. On a phone with a 200 ms round trip, nine sequential calls cost almost two seconds before any server did any work.
Most of those calls did not depend on each other. Slots did not need pricing. Stock did not need the user's promotions. They were in sequence because the code had grown one feature at a time, and each new feature added one more await at the end.
We moved all of it behind a single quote endpoint on the server, close to the other services, and made the server fetch in parallel. The app now makes one call and gets back a quote: the priced basket, the chosen slot, the fees, and an expiry time.
export async function buildQuote(cart: Cart, user: User): Promise<Quote> { const [prices, slot, stock] = await Promise.all([ pricing.price(cart.items, user.id), slots.reserve(cart.storeId, cart.slotId, 120), // hold for 2 minutes warehouse.check(cart.storeId, cart.items), ]); const lines = applyStock(prices.lines, stock); return quotes.save({ lines, slot, fees: fees(lines, slot), expiresInSec: 120 });}
That one change took the p95 from 2.8 seconds to about 1.3. The phone now paid for one round trip instead of nine.
Delete the screens
The checkout had five screens: basket, address, slot, payment method, review. Analytics showed that 83% of orders used the same address and the same payment method as last time, and that the review screen mostly repeated what the basket already showed.
We merged them into two. The first shows the basket with the slot, address and payment method filled from last time, each one changeable in place. The second is the payment itself. Every screen we removed was a navigation, a route bundle to download and parse, and a moment for someone to put the phone down.
This was the hardest part to get agreed, because each screen had an owner and a reason. What settled it was a recorded session of a real customer on one of our shelf phones, pausing for almost four seconds on the review screen before going back to the basket to check a price. Nobody argued for the review screen after that.
Ship less JavaScript
The checkout route loaded 410 KB of compressed JavaScript, of which about a third was a date library, a form library used for one field, and an analytics wrapper that loaded three more scripts. On a four-year-old phone, parsing and running that took around 600 ms before the first tap could register.
We replaced the date library with the browser's own Intl formatting, wrote the one form field by hand, and moved analytics to load after the payable screen was shown. The route dropped to 140 KB. Parse time on the median phone fell under 200 ms.
Most of the gains came from removing work, not from making work faster.
Make the slow part honest
Some things could not be made fast. The warehouse stock API, which we did not own, still took up to 400 ms on a bad day. Rather than block on it, the quote returns as soon as prices and slots are known, and marks lines whose stock is still being checked. The app shows those lines with a small pending mark, and updates them a moment later. In practice the update arrives before most people finish reading the total.
We also made the expiry visible. A quote holds prices and the slot for two minutes. If it expires, the app says that prices may have changed and shows the new total, with any difference called out. Before, an expired price caused a silent failure at payment, which looked like a bug to the customer and was one.
What we kept watching
A performance win slowly erodes unless something guards it. We added two checks to CI: a bundle size budget for the checkout route that fails the build if it grows more than 10 KB, and the recorded run on the shelf phones, which posts its p95 on every pull request. Twice in the following year a new feature pushed the number past one second, and twice the squad found a way back under before shipping.
If you are looking at a slow flow of your own, my advice is the order we followed. Measure on the phones your customers own, on the network they actually have. Find the calls that wait for each other and stop them waiting. Remove screens before you optimise them. Then ship less code. The 640 ms is not the point. The point is that a customer in a lift can buy their groceries before the doors open.