CogniPrep has three places where the user is looking at a screen that is not finished yet: a payment that has to be granted, a written exercise whose review is still running, and a recorded interview that is being processed. All three resolve somewhere else, in a webhook or a background job, and all three have to update a page that is already on screen.
They are implemented three different ways. I went looking for the inconsistency to tidy it up and ended up keeping all three, because the differences are each answering a different question: who owns the truth, and what ends the poll.
One: the purchase, which gives up
Every checkout returns the buyer to the thing they bought rather than to a receipt page. That creates a race, because the Stripe redirect is immediate while our webhook may land on a cold function. For a second or two the destination still considers the buyer unentitled, and would otherwise show them the buy panel they have just paid to be rid of.
/** ~30s of polling. Long enough for a cold webhook, short enough to not stall. */
const INTERVAL_MS = 2500;
const MAX_ATTEMPTS = 12;
useEffect(() => {
if (granted) return;
let attempts = 0;
const timer = setInterval(() => {
attempts += 1;
if (attempts > MAX_ATTEMPTS) {
clearInterval(timer);
setGaveUp(true);
return;
}
router.refresh();
}, INTERVAL_MS);
return () => clearInterval(timer);
}, [granted, router]);
Three things in there matter more than the interval.
granted is a prop, read on the server from the same row every access gate reads. Not from the Stripe redirect, which only says money moved, and not from client state. So router.refresh() is the entire update mechanism: it re-runs the server component, which re-reads the plan, and the parent then renders the real content. There is no second source of truth to keep in step.
The attempt counter exists because this poll has no terminal failure state of its own. If the grant has not landed in half a minute, something is wrong on our side, and an endless spinner is worth less to the buyer than a route to a human:
This is taking longer than usual. Your payment is safe. Reload the page, or contact support and we will activate it for you.
And the one-shot query parameter that brought the buyer here is only dropped once there is nothing left to wait for, because the parent decides whether to render this component from that same parameter. Clear it mid-poll and the next refresh comes back without it.
Two: the exercise review, which has no counter
The written review on a saved exercise attempt uses the same mechanism and deliberately does not count anything:
export function ExerciseReviewRefresher() {
const router = useRouter();
useEffect(() => {
const id = setInterval(() => router.refresh(), 5000);
return () => clearInterval(id);
}, [router]);
return null;
}
Ten lines, renders nothing. What stops it is not in the component at all, it is in the page that mounts it:
/**
* The attempts route scores written answers inside its own 60 second budget. A
* review still marked in progress well after that was cut off and will never
* finish, so it reads as failed rather than as a spinner that never stops.
*/
const REVIEW_STALE_MS = 2 * 60 * 1000;
function toPanelStatus(status: string, updatedAt: Date): AiPanelStatus {
if (status === 'complete' || status === 'failed') return status;
if (status === 'pending' || status === 'processing') {
return Date.now() - updatedAt.getTime() < REVIEW_STALE_MS ? 'processing' : 'failed';
}
return 'not_applicable';
}
The route that produces the review has a 60 second limit, so a row still claiming to be in progress two minutes later is not slow, it is dead: the function was killed and nothing will ever write the terminal status. Deriving that on read means the state machine has a failure state even though nothing wrote one, the page stops mounting the refresher, and the poll ends.
This is the version I would reach for first now. A counter in the component bounds the polling. A staleness rule on the server bounds the status, which bounds the polling for every reader of that row, including one who arrives an hour later on a fresh page load and should see "failed" rather than a spinner.
Three: the interview, which actually calls an API
The odd one out does not use router.refresh():
const POLL_INTERVAL_MS = 30_000;
export function useInterviewStatus(interviewId: string | null): InterviewStatusState {
// fetch(`/api/interview/sessions/${interviewId}`), set state,
// and stop the interval as soon as status is 'complete' or 'failed'
}
Two reasons it is different. The processing takes long enough that 30 seconds is the right cadence, and refreshing a whole route tree thirty times would re-run far more server work than reading one status. And the status drives client-side UI that is mid-flow, not a server-rendered panel, so the answer has to land in React state rather than in a re-render of the page.
The honest third reason is that this one is older than the other two. Its own comments still carry the requirement numbers from the spec it was built against, which is a nice archaeological marker for which parts of a codebase were written to a document and which were written to a bug.
What the three have in common
One sentence: every poll has a terminal condition that does not depend on the happy path.
An attempt cap, a staleness rule derived from the producer's own time budget, or a status enum with failure in it. Pick whichever you like, but pick one before you ship the interval, because the failure mode of forgetting is not a crash. It is a spinner that spins forever on a screen you will never see, burning a setInterval and one server render every few seconds in a tab somebody left open.
If you want to see the three surfaces these sit behind: the purchase confirmation is on whatever you bought from the pricing page, the reviewed written answers belong to the assessment centre exercises, and the processing one is the mock interview.
Top comments (0)