Back to Machine Coding
Machine Coding
easy
mid

How would you build a transaction confirmation flow?

State machine: idle → review → confirming → submitting → success | failure | timeout. Show a clear summary on review; require explicit confirm with an idempotency key; lock UI during submit; handle slow/failed network with retry; show distinct success/failure states with next actions. Disable double-submit. Server is source of truth — never trust a client 'success' flag.

5 min read·~35 min to think through

A confirmation flow looks small but it's a safety-critical UI — the difference between "charged once" and "charged three times" is in the details. Frame it as a state machine and reason about every transition.

1. States

ts
idle → review → confirming → submitting → { success | failure | timeout }
                       ↑__________________________|  (retry from failure)
  • idle — user hasn't initiated.
  • review — show details ("You'll pay $52.30 to Anya"); Confirm + Cancel buttons.
  • confirming — optional intermediate ("Are you sure?") for irreversible actions.
  • submitting — request in flight; UI locked.
  • success — done; show receipt + next action.
  • failure — show error + retry / contact.
  • timeout — submitted but no response; status unknown — special handling.

2. The component

jsx
function ConfirmFlow({ tx, onComplete }) {
  const [state, setState] = useState("review");
  const [error, setError] = useState(null);
  const idempotencyKey = useRef(crypto.randomUUID());      // ONE key per attempt

  const confirm = async () => {
    setState("submitting");
    try {
      const result = await api.submit(tx, {
        headers: { "Idempotency-Key": idempotencyKey.current },
        timeout: 15000,
      });
      setState("success");
      onComplete(result);
    } catch (e) {
      if (e.name === "TimeoutError") setState("timeout");
      else { setError(e); setState("failure"); }
    }
  };

  if (state === "review") return (
    <Review tx={tx} onCancel={onComplete} onConfirm={confirm} />
  );
  if (state === "submitting") return <Spinner message="Processing..." />;
  if (state === "success") return <SuccessReceipt result={result} />;
  if (state === "failure") return (
    <FailureCard error={error} onRetry={confirm} onCancel={onComplete} />
  );
  if (state === "timeout") return (
    <TimeoutCard
      onCheckStatus={() => api.getStatus(idempotencyKey.current)}
      onContactSupport={...}
    />
  );
}

3. Idempotency — the critical correctness piece

The user clicks Confirm. The request takes 8 seconds. The button doesn't disable properly because of a race. User clicks again. Now two charges.

Defenses:

  • Disable Confirm immediately on click; tie to state === "submitting".
  • Generate one idempotency key per attempt (useRef). Send it in the Idempotency-Key header. The server stores recent keys and returns the same result for a duplicate — no double-charge.
  • Retry uses the same key — the retry must be idempotent too. If you generate a new key on retry, you're creating a new attempt.

4. Timeout — the trickiest state

The request was sent; you never got a response. You don't know if it succeeded. Treat it specially:

  • Don't show "Failed" — that's misleading.
  • Offer a "Check status" action that queries by the idempotency key.
  • Optionally auto-poll once.
  • Recover the right state (success or failure) from the server.

5. UI rules

  • One primary action per state.
  • Show what you're charging / committing to in plain language.
  • Lock the UI during submit — overlay, disabled buttons, no Esc-to-close.
  • Show progress for slow operations (>2s) — at least a spinner with reassuring copy.
  • Distinguish success from failure visually — color, icon, copy — and provide next steps.
  • No auto-dismiss of success state if there's information (receipt) to preserve.

6. Server is source of truth

Never trust a client "I succeeded" flag. The receipt comes from the server response; on re-mount or refresh, fetch status from the server (by idempotency key or transaction id). The client UI is a view of server state.

7. Accessibility

  • role="dialog" for the modal shell; aria-modal="true".
  • Focus management — focus into the dialog; trap; return to trigger on dismiss.
  • role="alert" for the failure state — screen readers announce.
  • No color-only state — icon + text.
  • Loading state announced to assistive tech.

8. Optional confirmations (UX)

  • "Type the amount to confirm" for very large transfers.
  • "Hold to confirm" — a button you press for ~1 second; reduces accidental taps.
  • 2FA / biometric for elevated risk.

Interview framing

"State machine: review → submitting → success | failure | timeout. The two critical correctness pieces are (1) idempotency — generate a key per attempt, include in the header, retry with the same key; the server deduplicates, so a double-click can't double-charge; and (2) timeout handling — if you never got a response, you don't know the outcome, so offer a 'Check status' action instead of falsely showing failure. Lock the UI during submit, use one primary action per state, show what's being charged in plain language, and never rely on a client-side 'success' flag — the server is the source of truth. Accessibility: dialog roles, focus management, role='alert' for errors, no color-only states."

Follow-up questions

  • Why do you need an idempotency key? What does the server do with it?
  • Why is timeout special compared to failure?
  • Why retry with the *same* idempotency key?
  • How would you handle a page refresh mid-submit?

Common mistakes

  • No idempotency — double-click double-charges.
  • Treating timeout as failure — misleads the user.
  • Generating a new key on retry — multiple charges if the original succeeded.
  • Client-trusted success without server confirmation.
  • Allowing Esc to close mid-submit and leaving an orphan transaction.

Performance considerations

  • Not a performance question, but: don't block the main thread; show progress within 100ms; keep the submit endpoint fast (P95 < 2s for the happy path).

Edge cases

  • Page refresh between Confirm and response.
  • Network drops mid-flight.
  • User closes laptop, returns next day.
  • Slow backend — show progress, don't time out at 5s.

Real-world examples

  • Stripe Elements / payment confirmation.
  • Banking transfer flows (always idempotent + 2FA).
  • GitHub destructive-action confirmations (type repo name).

Senior engineer discussion

Seniors model the flow as an explicit state machine, treat idempotency as table stakes (per-attempt key, retry with same), handle timeouts as 'unknown' rather than 'failure', never trust client success flags, and add friction proportional to risk (hold-to-confirm, 2FA for elevated actions).

Related questions