LPI Web Dev Essentials study materials
LPI Web Dev Essentials study resources

Checkout Shipping Rates with Async Loop and Stale Responses

LPI Web Dev Essentials · Put your knowledge to work in context.

All case studies

Checkout Shipping Rates with Async Loop and Stale Responses

QuickCart's checkout fetches 12 shipping options, one request per cart item, from /rates?sku=. The code uses items.forEach(async item => { const r = await fetch('/rates?sku=' + item.sku); rates.push(...); renderTotal(); }). The displayed total sometimes excludes the last item. A fast double-click on "Apply coupon" sends two requests; the slower response wins and shows an expired coupon as applied. A var loop attaches click handlers to 12 "select rate" buttons, and every handler reads the last rate. An unhandled rejection appears when /rates returns 500, and the page silently keeps old totals. A setInterval polls /cart every two seconds and is never cleared on navigation, so duplicate requests continue after back/forward. The team uses no build step, targets evergreen browsers, and wants readable code.

1. Explain why the total can exclude the last item, then rewrite the rate-fetching flow so all items are accounted for and a single failed SKU does not erase successful rates.

Read the worked answer

forEach does not await the async callback. It starts all 12 requests and returns immediately, so code after the loop and the individual callbacks race. renderTotal() runs whenever any request resolves, reading a rates array that may still be incomplete. The last item is often missing because its response arrives after the final render, or because push order does not match item order. Rewrite with Promise.allSettled(items.map(...)): each task returns a result object rather than mutating shared state. After all settle, partition fulfilled and rejected results, render rates in the original item order, and show a non-blocking message for failed SKUs. If partial results should appear early, use a counter or Promise.all on a per-item validation step, but render from a derived state object, not from an array being mutated by concurrent callbacks. Add a top-level try/catch or .catch on the chain so a 500 is handled. This preserves readability because the data flow is explicit: fetch all, classify, render once.

2. Fix the double-submit and out-of-order coupon responses. Describe the client-side controls and the HTTP-level assumptions the fix depends on.

Read the worked answer

Disable the button and set an in-flight flag immediately on the first click, then re-enable it in finally. That prevents the second request in the common case, but it does not solve a response that is already in flight or a user who triggers the action from the keyboard. Use an AbortController per logical coupon attempt: store the current controller, abort it before starting a new request, and ignore AbortError in the catch. Tag each request with a monotonically increasing attempt id and only apply a response whose id matches the latest attempt. This makes the slower, older response harmless even if it arrives later. The HTTP layer must cooperate: the coupon endpoint should be idempotent for the same coupon code and cart, and it should return a clear status such as 409 for an expired coupon rather than a generic 200 with an error body. Do not rely on response timing or on setTimeout. Also clear the in-flight state on pagehide and on navigation so the UI does not remain disabled.

3. Fix the `var` loop handler bug and the polling leak. Explain the closure behaviour and the cleanup point you would use.

Read the worked answer

With var, there is one function-scoped binding shared by all 12 handlers; by the time a click occurs, the loop has finished and every handler reads the final rate. Replacing var with let creates a new binding per iteration, so each handler closes over its own rate. Alternatively, attach the rate via data-rate-id and read it from event.currentTarget, which avoids closures entirely and is more robust if the DOM is re-rendered. For polling, store the interval id and clear it in a pagehide or beforeunload listener, and also when the checkout view is torn down. pagehide is preferable for back/forward cache compatibility; do not rely on unload. If the page can be restored from the bfcache, restart polling on pageshow when event.persisted is true. Guard the poll callback with an in-flight flag so a slow /cart response does not overlap the next tick, and stop polling after a terminal order state.