Checkout rebuild for Pasarloka
Cut the checkout from five screens to two and the slowest step from 2.8 seconds to 640 milliseconds. Pasarloka: same-day grocery delivery, Klang Valley and Penang.
+11.4%
- Pasarloka
- Product engineer, checkout squad
- 4 months
- 2025.08, v2.4
- TypeScript, Next.js, Node, Redis, PostgreSQL
Problem
Most Pasarloka orders are placed on mid-range Android phones on mobile data, often in a lift or a car park. The checkout called nine services in sequence before it showed a total, and the p95 time to a payable screen was 2.8 seconds on a good day.
Worse, a timeout after payment left people unsure if they had paid. Support handled about 140 duplicate-order tickets a week, and each one needed a manual refund.
Constraints
- Slot availability and stock change by the minute, so the total cannot be cached for long.
- Four payment methods with different confirmation timings: card, FPX online banking, two e-wallets.
- The squad could not change the warehouse system, only call it.
Architecture
Code
The quote id doubles as the idempotency key, so a retried tap returns the same order instead of a second one.
export async function createOrder(quoteId: string, userId: string) { // One quote can only ever become one order. const existing = await db.order.findUnique({ where: { quoteId } }); if (existing) return existing; const quote = await quotes.get(quoteId); if (!quote || quote.expiresAt < Date.now()) { throw new CheckoutError("QUOTE_EXPIRED", "Prices changed. Review your basket."); } return db.order.create({ data: { quoteId, userId, totalSen: quote.totalSen, slotId: quote.slotId }, });}
Result
- p95 time to a payable screen
- 640 ms
- checkout completion over 8 weeks
- +11.4%
- duplicate-order tickets
- 6 a week
- from basket to paid
- 2 screens
The quote pattern was later reused for the scheduled weekly basket, which shipped in three weeks instead of the planned two months.