Double orders, a 302 that flips methods, and a coupon that never arrives
An e-commerce checkout posts to POST /orders. The submit button is a <button> inside the form with a JS click handler that calls fetch and then sets location.href = '/thank-you'. Analytics show 3.1% of orders are created twice, always within 900 ms of each other. The API takes 1.8 s at p95 and the handler never disables the button. /thank-you is a static page, but support reports that pressing Back after ordering shows a stale confirmation carrying the previous customer's order number on shared kiosk browsers. Separately, a partner integration posts JSON to /api/orders, receives a 302 to /thank-you, follows it, and complains that it got HTML where it expected JSON; its automatic retry after a network timeout also produced a second order. The form marks every input autocomplete="off", uses <input type="text"> for email and card number, and the coupon field has no name attribute - coupons are silently never applied.
1. Design a defence that eliminates duplicate orders end to end. Explain why disabling the button is necessary but not sufficient, and what the server must do.
Read the worked answer
Disabling the button on submit and showing a busy state is the first layer, because it removes the most common cause here: a second click during the 1.8 s wait. But it is only a UI hint. It fails when JavaScript has not yet loaded or has thrown, when the user double-taps before the listener attaches, when the browser or a proxy replays a request, when the user hits refresh, and when the partner's HTTP client retries after a timeout - and that retry is exactly what produced the second order in the partner case. Client-side prevention cannot make a non-idempotent POST safe over an unreliable network.
The server needs an idempotency key. Generate a random key when the form is rendered, embed it as a hidden field (and accept it as an Idempotency-Key header for the API), and send it with the request. The server stores the key with the created order id in a unique index, inside the same transaction that creates the order. If a request arrives with a key that already exists, the server does not create anything; it returns the original result, ideally a 200 with the same order id, so the client and the partner both see success. Set an expiry (24 hours is typical) and reject mismatched payloads for the same key with 409/422 so a key cannot be reused for a different order.
Also submit the form natively where possible so the no-JS path works, and treat the fetch handler as an enhancement that prevents the full page load rather than as the only path. Finally, log the duplication ratio as a metric - if it does not drop to zero, the layer that is failing is identifiable from the request timing.
2. Specify the redirect and caching behaviour for the post-purchase flow. Compare 302, 303, and 307, and explain how the Back button on a shared kiosk ends up showing another customer's confirmation.
Read the worked answer
The correct pattern is Post/Redirect/Get: the POST creates the order and responds with 303 See Other and Location: /orders/1234/confirmation. The browser then issues a GET, so reloading or pressing Back re-fetches a GET rather than re-submitting the order.
Status codes are not interchangeable:
- 303 explicitly means "the result is elsewhere, fetch it with GET", regardless of the original method. This is what you want after a form POST.
- 307 preserves the method and body, so following it re-sends the POST - which is precisely the partner's failure mode when they follow a redirect after posting JSON.
- 302 is historically ambiguous; browsers convert POST to GET in practice, but the spec never promised it, so clients are entitled to behave differently. Do not rely on it. Use 303 for humans, and for the JSON API return
201 Createdwith aLocationheader and a JSON body rather than redirecting at all - an API client should never have to follow a redirect to learn the order id.
The kiosk problem is cache plus a shared device: /thank-you is a static asset, so the browser or an intermediary can serve it from cache on Back, and the order number in it was rendered on the server for whoever last loaded it. Fixes: make the confirmation a per-order resource behind an authorisation check, and send Cache-Control: no-store (plus Pragma: no-cache if you still care about HTTP/1.0 proxies) on the confirmation response so it is never reused. Do not make it publicly cacheable even for a short max-age. For a kiosk build, also clear session state on idle timeout, so Back has nothing to restore.
3. List the form markup defects and the concrete changes you would make, justifying each. Include the coupon bug and the autocomplete decision.
Read the worked answer
Coupon never applied. A control without a name is not a successful control: it is omitted from the submitted application/x-www-form-urlencoded/multipart body and from FormData. Add name="coupon" (and an id so the label's for works). This is a one-line bug with a silent failure, so it belongs in a test that asserts the request body, not just that the field renders.
Input types and keyboards. <input type="email"> gives the email keyboard on mobile and free format validation; card number should be inputmode="numeric" with autocomplete="cc-number" - do not use type="number", because leading zeros are lost and spinners are wrong for a 16-digit string. Add pattern/maxlength only where they match the real format, and never rely on client validation alone.
Autocomplete. autocomplete="off" on every field is wrong on two counts: browsers increasingly ignore it on login and payment fields, and it removes the exact feature users need. Use the tokens: autocomplete="email", autocomplete="cc-number", autocomplete="cc-exp", autocomplete="cc-csc", autocomplete="postal-code", autocomplete="one-time-code" for the SMS code. Only genuinely ephemeral, non-user data (a search query, a honeypot) should be off.
Labels and errors. Every input needs a persistently visible <label for> - placeholder-only labelling disappears when typing and fails contrast and screen reader expectations. Errors need to be programmatically associated, via aria-describedby pointing at the message element and aria-invalid="true" on the field, with the message text in the DOM rather than a red border alone. On submit failure, move focus to the first invalid field or to an error summary with links.
Button semantics. Keep <button type="submit"> inside the form so Enter-to-submit and the no-JS path work; if the fetch handler calls preventDefault(), ensure it runs after native validation (novalidate only if you reimplement it fully).