Three ways for a payment to finish, and my component only handled one
A payment flow has three exits: the user finishes it, the user closes the window, or the component that started it is already gone. Mine only handled the first one.
By Meet ModiConfirming a course payment meant three steps: create an order on the backend, hand off to a third-party checkout window, then poll an endpoint until the backend confirmed the payment actually cleared. The gateway doesn't always confirm synchronously, so polling was load-bearing, not decorative.
In testing, this worked every time. In production, a chunk of users landed on a spinner that never resolved, and a smaller chunk got charged an error toast for a payment that had actually succeeded.
The bug: my polling loop assumed the component asking the question would still be around to hear the answer. It usually was.
Take 1: a boolean and a setInterval
function PaymentButton({ courseId }: { courseId: string }) {
const [loading, setLoading] = useState(false);
async function handlePay() {
setLoading(true);
const order = await createOrder(courseId);
openCheckoutWindow(order.id);
const interval = setInterval(async () => {
const status = await checkPaymentStatus(order.id);
if (status === 'confirmed') {
clearInterval(interval);
setLoading(false);
router.push('/course/' + courseId);
} else if (status === 'failed') {
clearInterval(interval);
setLoading(false);
showError('Payment failed');
}
}, 2000);
}
return <button onClick={handlePay} disabled={loading}>Pay</button>;
}Two clicks on Pay before the first order finishes creating spins up two intervals, each polling the same status endpoint, each ready to call setState on the same component. Whichever one resolves second wins, even if it's answering a stale question.
The other failure mode: the user navigates away, or the modal hosting this button unmounts, while an interval is still ticking. The poll fires anyway, gets a response, and calls setState on a component that no longer exists.
Take 2: an explicit state machine with ref guards
I replaced the boolean with named states, because "loading" was doing the work of five different situations. Then I added refs for the things that needed to survive across renders and unmounts without triggering re-renders themselves.
type PaymentState = 'idle' | 'creatingOrder' | 'awaitingUser' | 'verifying' | 'resolved';
const MAX_POLL_ATTEMPTS = 15;
function usePaymentFlow(courseId: string) {
const [state, setState] = useState<PaymentState>('idle');
const mountedRef = useRef(true);
const verifyingRef = useRef(false);
const attemptRef = useRef(0);
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
async function pay() {
setState('creatingOrder');
const order = await createOrder(courseId);
if (!mountedRef.current) return;
setState('awaitingUser');
openCheckoutWindow(order.id);
verify(order.id);
}
async function verify(orderId: string) {
if (verifyingRef.current) return;
verifyingRef.current = true;
attemptRef.current = 0;
setState('verifying');
while (attemptRef.current < MAX_POLL_ATTEMPTS) {
if (!mountedRef.current) return;
const status = await checkPaymentStatus(orderId);
if (!mountedRef.current) return;
if (status === 'confirmed' || status === 'failed') {
verifyingRef.current = false;
setState('resolved');
return status;
}
attemptRef.current += 1;
await sleep(2000);
}
verifyingRef.current = false;
setState('resolved');
return 'unknown';
}
return { state, pay };
}The mounted-ref check runs before every setState call, so a response arriving after unmount just gets dropped on the floor instead of crashing or corrupting state. The verifying-ref stops a second call to pay from starting a second overlapping poll, since the flag is already true from the first one. Every setState is gated on both.
The attempt counter turned an infinite loop into a bounded one. After 15 tries at two seconds apart, the flow gives up and tells the user to check their order status manually instead of spinning forever on a gateway that's gone quiet.
What I should have done first
I should have written down the three ways this flow actually ends before writing the happy path: the user finishes, the user bails, the component is gone by the time the answer arrives. Take 1 only handled the first one because that's the one I tested by clicking the button myself and waiting. Nobody manually tests closing a modal mid-poll unless they're specifically looking for that bug, and I wasn't, until production found it for me.
More Posts
The vector database was innocent
I built a RAG service over the OpenTelemetry docs, then pointed OpenTelemetry back at it to find out why answers took 16 seconds. It wasn't the LLM. It wasn't the vector search either.
A query that returned nothing nested inside it
The exact same logical query returned full nested data through the REST layer and empty shells through the service layer underneath it. Same fields requested. Same backend. One shorthand parameter that only one of the two actually understood.
One field, three different envelopes, and a fix that made it worse
The same subscription-status field came back nested two levels deep, one level deep, or bare at the top, depending on account state. My fix didn't catch that. It added math on top of a check that was already wrong.