QA Interview
Fix Delayed Voice Interview Scoring
Fix delayed voice interview scoring by tracing session IDs, provider events, polling states, Edge Function responses, persistence, and safe recovery steps.
22 min read | 3,561 words
TL;DR
Preserve the original voice session ID, prove that trusted completion events started polling, inspect the Edge Function response, and recover the same session instead of starting a second interview.
Key Takeaways
- Preserve the provider session ID before retrying any result request or leaving the live interview screen.
- Separate a missing stop signal from a provider result that is valid but still pending.
- Treat the client polling limit as a bounded wait, not proof that provider scoring failed.
- Read Edge Function status codes and response bodies before changing polling or persistence logic.
- Use both the existing-row lookup and the unique voice session index to contain duplicate saves.
- Keep a future webhook path idempotent and compatible with the current client polling fallback.
To fix delayed voice interview scoring, preserve the session ID, confirm that a trusted stop event started polling, and inspect each result response until the API says the score is done. Separate a genuinely pending result from an Edge Function failure. Retry retrieval with the same session ID, never start a second run just to recover a score.
QAJobFit's current path spans an embedded voice session, browser messages, a bounded polling loop, a Supabase Edge Function, a score API, and a Postgres save. A delay can happen at any step. This guide traces the shipped implementation in VoiceInterview.tsx, index.ts, 20260712090000_add_interview_prep.sql, and types.ts, then turns each branch into a concrete cause and next fix.
1. Why Fix Delayed Voice Interview Scoring Now?
A delayed result is not one generic failure. The UI can be waiting for a trusted stop signal, waiting for the score API to finish, handling a failed function call, or rendering a completed session whose DB insert failed. The correct repair depends on which transition did not happen, so restarting first destroys useful proof and may consume one more allowance.
The Phase type in VoiceInterview.tsx lists the visible states: preflight, confirm, loading, live, scoring, results, and error. A user who sees the scoring label is now in scoring. A user who remains inside the live iframe is not. That distinction is the first clue when a voice interview score still processing report arrives.
| Visible state or message | Triggering branch | Likely cause | Evidence to collect | Next action |
|---|---|---|---|---|
| Live iframe remains visible | No accepted stop event and timer remains above zero | The session is active, or its message was ignored | message event origin, event name, session ID |
Validate the event contract and wait for the timer fallback |
| Scoring spinner remains visible | pollResult is active |
Provider has not returned completed, or a request is slow |
Result request count, status body, request duration | Keep the same session ID and inspect polling |
| Score is still processing | Forty result checks completed without completed |
Provider stayed pending past the client boundary | Last API status and timestamps | Resume result retrieval for the same session |
| Score could not be loaded | A result invocation threw | Auth, network, function, or provider fetch failed | HTTP status, response body, function logs | Repair the failing layer, then retry the same ID |
| Completed without a score | Result branch returned evaluation: null |
Automated scoring key was not configured | Completed payload and note | Preserve the session and treat the note as the result |
A score is useful only after its context is intact. Compare the recovered result with score log, but do not interpret one delay as a lower score. For scoring criteria rather than delivery mechanics, use the scoring rubric.
2. How Do You Fix Delayed Voice Interview Scoring?
Use one clear sequence based on proof. The goal is to identify the last confirmed step, then resume from there. Do not create a replacement session while the first score job may still be running.
- Record the session ID from the trusted message or network proof.
- Confirm whether
pollResult(sessionId)ran afteronStop,onSubmit,onTerminated, or timer expiry. - Inspect every
interview-voicerequest withaction: 'result', including its HTTP status and JSON body. - Classify the response as pending, completed, client call failure, or server or API failure.
- If it is pending, continue retrieval for the same session within an explicit time budget.
- If it failed, repair authentication, configuration, network, or API access before retrying.
- When scoring ends, verify the row identified by
voice_session_idand compare the returned score with saved data. - Start a new interview only when the same session is proven unrecoverable and product policy permits a new reservation.
The client wraps each request in voiceRequest, which calls supabase.functions.invoke('interview-voice', { body }). The official Supabase invoke reference shows that the client exposes both data and error. This wrapper throws whenever error is present. Therefore, live interview result polling reaches the catch branch before it can inspect an application payload when invocation itself fails.
async function voiceRequest<T>(body: Record<string, unknown>): Promise<T> {
const { data, error } = await supabase.functions.invoke('interview-voice', { body });
if (error) throw error;
return data as T;
}
Keep the interview prep page open in the same tab when possible. Do not start a new live session as a shortcut. Keep the session's interview topic and answer context available, then use answer depth guide only after the result arrives. That order keeps a transport problem separate from the quality of the candidate's answer.
3. Trace Stop Events Into the Polling Loop
start() receives iframeSrc and maxMinutes from the Edge Function. It rejects a missing URL, constructs a URL, requires HTTPS, and stores embedUrl.origin in providerOriginRef. Only after those checks does it place the component in the live phase. The stored origin becomes the trust boundary for stop messages.
While the phase is live, a message listener rejects any event whose origin differs from providerOriginRef.current. It also rejects null values and non-object data. If the accepted object contains a string sessionId, that value enters sessionIdRef. Only onStop, onSubmit, or onTerminated can start result polling, and only when the ref already contains an ID.
const listener = (event: MessageEvent) => {
if (event.origin !== providerOriginRef.current ||
typeof event.data !== 'object' || !event.data) return;
const data = event.data as { sessionId?: unknown; event?: unknown };
if (typeof data.sessionId === 'string') sessionIdRef.current = data.sessionId;
if (['onStop', 'onSubmit', 'onTerminated'].includes(String(data.event)) &&
sessionIdRef.current) {
void pollResult(sessionIdRef.current);
}
};
This is a browser message, not a server webhook. The MDN postMessage security guidance recommends checking the sender origin and validating message syntax. QAJobFit performs both checks at a basic level. When debugging, capture event.origin, the data type, the event name, and whether the same accepted object supplied the session ID.
One subtle race deserves a targeted test. If a trusted stop message arrives without an ID, it cannot start polling.
If a later trusted message supplies only the ID, it updates the ref but still does not call pollResult. The timer becomes the remaining trigger. This is different from a provider result pending status because no result request has started yet.
The timer is the second stop path. Every second it reduces timeLeft.
At zero it calls pollResult when an ID exists. Without an ID, it renders results with the note that the timed session ended before a scored session ID was received. Review the event proof before using the score log, because a missing row cannot establish a score trend.
4. Distinguish Missing Session IDs From Pending Provider Results
A missing voice session ID is a client-side identity problem. sessionIdRef begins as null, is not included in the start response, and is populated only by an accepted iframe message. When it remains null, the browser has no identifier to send with action: 'result', so there is no API status to interpret.
A pending result is different. The Edge Function has already received a nonempty session ID, trimmed it, limited it to 200 characters, and called the provider's session endpoint. If the API payload has any status other than completed, the function returns that status, or pending when the API omitted one. The browser then waits and asks again.
Use network proof to separate the two cases. Zero result requests plus a timer-end note points to absent session identity. Repeated successful result requests whose JSON bodies contain a non-completed status prove that identity exists and the result remains pending. A request carrying an empty value receives 400 with sessionId is required, which is a third, malformed-request case.
The current UI does not persist sessionIdRef in local storage, route state, or Postgres before the score ends. A reload can therefore remove the browser's only copy even while the host still has the session. That behavior is visible in VoiceInterview.tsx. It is a reason to capture the ID during incident handling, not a claim that ordinary users can resume it through the current UI.
For a restart-safe design, save an opaque recovery key when a trusted ID arrives. Clear it after the score ends, and limit access to the signed-in owner. Do not place API secrets or score payloads in browser storage. Once the score is recovered, scoring rubric can guide practice without confusing delivery failure with answer quality.
5. Read the 40-Attempt, Three-Second Poll Boundary
The polling loop makes at most 40 result requests. Attempts are numbered internally from 0 through 39, and each non-completed response is followed by window.setTimeout(resolve, 3000). The code even performs the final three-second wait before leaving the loop, so the deliberate sleep budget totals 120 seconds, plus the duration of all network and server work.
for (let attempt = 0; attempt < 40; attempt += 1) {
const result = await voiceRequest({ action: 'result', sessionId });
if (result.status === 'completed') {
setEvaluation(result.evaluation ?? null);
setImprovement(result.improvement ?? null);
setNote(result.note ?? '');
setPhase('results');
return;
}
await new Promise((resolve) => window.setTimeout(resolve, 3000));
}
Three seconds is a minimum scheduled delay, not an exact interval. This limit helps fix delayed voice interview scoring without marking a slow result as failed.
The MDN setTimeout reference notes that actual execution can occur later than requested. Result request latency, browser scheduling, and background-tab throttling can all extend the wall-clock period. Therefore, do not report this loop as an exact two-minute timeout.
| Point in the sequence | Client action | Possible response | Next state |
|---|---|---|---|
| Trusted stop event or timer expiry | Call pollResult(sessionId) |
Guard already active | Existing poll continues |
| Attempt 1 | Invoke result action | completed |
Store response data and render results |
| Attempts 1 through 40 | Invoke, then wait after non-completion | Provider status such as pending | Continue same session |
| After final wait | Set processing error | No completed response seen | Render error with Back action |
| Any thrown invocation | Enter catch branch | Function or transport error | Render load error |
| Finally block | Clear pollingRef |
Success or failure | A later explicit call may poll again |
pollingRef.current is the local concurrency guard. It blocks a stop event and timer expiry from launching overlapping loops, but it does not survive an unmount and does not coordinate separate tabs. It also resets in finally, so the same mounted UI could poll again only if a new trigger calls the callback after the prior loop ends.
When the client reaches its limit, the message says the interview completed but the score is still processing. That wording does not prove the score is ready. It means the browser observed no completed response during its bounded checks. Preserve the ID, record the final status, and defer score interpretation until answer depth guide has actual score data.
6. What Does Each Error Message Actually Mean?
The message "The interview completed, but the score could not be loaded" comes only from the pollResult catch branch. A report that the voice interview score could not load points to this same branch, even though the UI message is longer. A Supabase Edge Function scoring error can reach that branch through authentication failure, a non-success function response exposed as a call error, connectivity failure, or an unexpected server exception. The user-facing string deliberately does not identify which layer failed.
The message "The interview completed, but the score is still processing" follows 40 non-completed iterations. It is a poll limit result, not the catch path. The note "The timed session ended before a scored session ID was received" follows timer expiry with a null ref and renders in results, not error.
| User-facing outcome | Code path | What it proves | What it does not prove |
|---|---|---|---|
| Score is still processing | Loop exhausted | No request returned completed inside the loop |
Provider failed or lost the session |
| Score could not be loaded | voiceRequest threw |
At least one call failed | Whether earlier API checks were pending |
| Timed session ended before an ID | Timer found a null ref | Browser lacked a usable session ID at that moment | Whether the provider created a session |
| Automated scoring was not available | Completed response has no evaluation | UI received no score object | Candidate performed poorly |
| Numeric score appears | Evaluation object exists | UI derived a rounded number | Persistence definitely succeeded |
Open the browser's result request before editing code. Record status, response JSON, duration, and attempt number. Then inspect the corresponding Edge Function log with the same time range. Supabase documents distinct HTTP, relay, and fetch error classes in its function call error example, while the current wrapper collapses them into one thrown value and one UI message.
The score renderer uses final_score, then overall_score, then zero, and rounds the chosen numeric conversion. The server also clamps its stored score from 0 through 100. These defaults protect the shape, but a displayed zero with a score object must be checked against the raw payload rather than assumed to be a valid score.
Once transport is healthy, open score log to confirm whether the row was saved. Use scoring rubric to interpret categories only after the session is visible and its payload is complete.
7. Verify the Edge Function Result Branch
The result branch in supabase/functions/interview-voice/index.ts runs only after authentication and profile lookup. It first rejects an unconfigured live session with 503, rejects a blank session ID with 400, and handles a missing API key as a completed result with null score data plus a short note. That last response is not a pending state.
With an API key, the function sends a GET request with provider headers to the encoded session endpoint. An unsuccessful upstream response becomes 502 with Could not fetch the voice interview result. A successful response whose status is not completed becomes a normal 200 JSON status, allowing the client to keep polling without treating pending work as a function failure.
if (!providerResponse.ok) {
return json({ message: 'Could not fetch the voice interview result' }, 502);
}
if (providerData?.status !== 'completed') {
return json({ status: providerData?.status || 'pending' });
}
When scoring ends, the function extracts evaluation_results, improvement_results, and transcript_url. It derives a rounded score from final_score or overall_score, clamps it to the inclusive 0 through 100 range, checks for an existing row by voice_session_id, and inserts only when that lookup finds none. A save failure is logged, but the function still returns the completed score payload to the browser.
To fix delayed voice interview scoring at this layer, separate score delivery from the DB save. A candidate can see a result even when no row was saved, so a completed UI is not proof of persistence. Conversely, a stored row can confirm that the score was ready even if the browser later lost the response. The typed interview_sessions definition in src/integrations/supabase/types.ts exposes voice_session_id, voice_scenario_id, voice_evaluation, and voice_transcript_url as nullable fields for that check.
Where would a webhook fit?
The shipped function has quota, start, and result actions. It has no webhook branch.
Supabase describes Edge Functions as suitable for third-party integrations and webhook receivers, but that general capability does not prove this voice vendor exposes a callback. It also does not define a signature scheme. Confirm the vendor docs before implementing one.
A safe callback design would authenticate the vendor separately from the current user JWT path, validate the exact event shape, map the event to one voice_session_id, and make persistence idempotent. It should acknowledge valid callbacks promptly and let the browser keep polling as a fallback. Never trust a public session ID without a vendor-supported authenticity check, and never let a callback select someone else's row.
// Proposed shape only. Provider-specific verification is intentionally omitted.
const verifiedEvent = await verifyProviderCallback(request);
const sessionId = verifiedEvent.sessionId;
await persistCompletedSessionOnce(sessionId, verifiedEvent.result);
return json({ received: true });
For current incidents, do not wait for a webhook that does not exist. Reinvoke the existing result action with the preserved session ID, inspect the upstream response through server logs, and return to the interview prep after the same result is recovered.
8. Prevent Duplicate Saves and Repeated Polls
Duplicate interview result prevention happens in two layers. In the browser, pollingRef prevents overlapping loops inside one mounted UI. On the server, the function queries interview_sessions for the same voice_session_id and skips insertion when a row already exists.
The migration adds a stronger DB rule. uq_interview_sessions_voice_session is a unique partial index on voice_session_id where the value is not null. Two concurrent requests can both complete the pre-insert lookup, but only one can commit the same non-null session ID; the other insert receives a uniqueness error and logs it.
CREATE UNIQUE INDEX IF NOT EXISTS uq_interview_sessions_voice_session
ON public.interview_sessions(voice_session_id)
WHERE voice_session_id IS NOT NULL;
The unique key is global to the session ID, not a composite of user and session. That matches the assumption that one voice session maps to one result row. Row Level Security lets users view, create, and delete their own sessions, while the Edge Function uses its service client for the save branch after authenticating the caller.
Keep each retry and webhook idempotent. Repeated result reads are safe only when every completed response converges on the same stored session. If richer fields need updating later, use a DB operation whose conflict target is voice_session_id, and define which completed payload wins rather than weakening the unique index.
A missing saved row after a visible score should trigger a save check, not a new run. Compare the response with score log, check server logs for Could not persist voice interview result, and retain the same ID. The score delivery and the DB write are related operations, but the current function does not make them one transaction.
9. Build a Reproduction and Recovery Checklist
The safest way to fix delayed voice interview scoring is to reproduce one step at a time. Use a signed-in test account with available quota, keep DevTools open before starting, and record the result requests without exposing authorization tokens. Run the session through a normal stop, a timer expiry, a slow score response, and a simulated function failure in an authorized non-production environment.
Start with the browser event path. Confirm that the iframe URL is HTTPS and note its computed origin. Capture trusted messages only in a controlled test build, then verify the session ID type and each accepted event name. Test a stop event with an ID, a stop event without one, an ID-only event that follows it, and an event from a mismatched origin.
Next, test the poll limit. Stub or control result responses so attempts remain pending before returning completed, and verify that only one loop runs. Check that the UI moves from live to scoring to results, and separately prove that 40 non-completed responses produce the processing message without creating a second session.
A focused debug log should contain the following non-secret proof. Keep each item tied to the same session ID:
- Signed-in user reference suitable for internal support lookup, never an access token.
- Provider session ID and scenario ID, redacted in shared screenshots when required.
- Trusted stop event name, event time, and whether the same message included the ID.
- Result attempt times, HTTP statuses, returned API statuses, and the final response body.
- Edge Function request time, status code, and relevant log entry without environment values.
- Presence or absence of one
interview_sessionsrow for the voice session ID. - Whether the browser remained mounted, moved to error, reloaded, or closed.
Use the facts, not the symptom alone. For a pending API status, retry the result action for the same ID after an operator-approved delay.
For a 401, restore the user's signed-in session before invoking again. For 503, fix environment setup. For 502, inspect upstream health and credentials. For 500, use the server log to find the thrown branch.
When the ID is missing from the browser, do not manufacture one. Recover it only from trusted host or server logs available to authorized operators. If no trusted record ties an ID to the session, document the session as unscored and apply the product's allowance policy rather than attaching someone else's result.
For a completed payload with no saved row, query by the exact voice_session_id and review the insert error. The unique index may mean a concurrent request saved it first. A different DB error needs its own repair. Do not insert an edited score by hand unless an audited support procedure preserves ownership, provenance, and the raw API payload.
For a future webhook, test duplicate delivery, reordered delivery, an invalid signature, a completed event after the client timeout, and a callback racing the poller. The expected result is one session row and one stable score. Polling should still recover the result if the callback is delayed, while a callback should allow later polling to read the already saved result.
Finally, verify the candidate-facing outcome. The recovered result should render one score, strengths, improvement areas, detailed feedback, and report-card items only when those fields exist. Then use answer depth guide for response improvement and scoring rubric for category analysis. Return to interview prep only after confirming the same session is complete.
Conclusion: Preserve the Session Before Retrying
To fix delayed voice interview scoring, identify the last confirmed transition and preserve the same session ID. Trusted browser events start the pull path, bounded polling reads API status, the Edge Function distinguishes pending from upstream failure, and the unique DB index prevents a second result row. A timeout ends the client's wait, not necessarily the provider's work.
Use the fix checklist before starting a new session. Once the same result is loaded and its saved row is confirmed, continue targeted practice in the interview prep and use score log to compare complete sessions under consistent conditions.
Interview Questions and Answers
How would you diagnose delayed voice interview scoring in this application?
I would trace the last confirmed state transition from trusted iframe message to `pollResult`, Edge Function response, provider status, and database row. I would preserve the session ID and classify the failure as missing identity, pending work, invocation failure, or persistence failure. Recovery would reuse the same session.
Why does the message listener validate event.origin?
The iframe is cross-origin, so any accepted message can influence session identity and trigger result polling. Comparing `event.origin` with the origin derived from the validated embed URL rejects messages from unrelated origins. The handler also checks that data is a non-null object before reading its fields.
What is the real timeout of the polling loop?
The code schedules 40 waits of three seconds after non-completed responses, for 120 seconds of deliberate delay. Network requests and browser scheduling add more time. I would describe it as a bounded 40-attempt poll, not an exact two-minute timeout.
How does the result action distinguish pending work from failure?
A successful provider response with a status other than `completed` returns a normal status payload, so the browser continues polling. An unsuccessful provider HTTP response becomes a 502 response. Configuration, authentication, validation, and unexpected exceptions use their own server error branches.
How does the application prevent duplicate voice interview rows?
The function checks `interview_sessions` for the provider session ID before inserting. The database also has a unique partial index on each non-null `voice_session_id`. The index is the final concurrency guard when two completed requests race after both observe no existing row.
How would you add webhook recovery without breaking polling?
I would first confirm the provider's callback and signature contract. The callback would verify authenticity, map one session ID to its owner, and persist completion idempotently. The result action would remain available, allowing client polling to read the same completed record if callback delivery is late.
Why can a candidate see a score that is absent from history?
After provider completion, the function attempts persistence but only logs a save error. It still returns the evaluation to the browser, so result rendering can succeed without a stored row. I would inspect the insert error and query by the exact voice session ID before retrying any write.
Why should support preserve the original voice session ID during recovery?
The session ID links trusted completion events, provider status, result polling, and the unique persistence key. Reusing it lets support recover the same attempt and distinguish a pending result from a missing row. Starting another interview creates new evidence and can consume another allowance without resolving the first failure.
Frequently Asked Questions
Why is my voice interview score still processing after the session ended?
The client may have completed all 40 result checks without receiving a completed provider status. That means its bounded wait ended, not that the session disappeared. Preserve the original session ID, record the last returned status, and retry result retrieval through the same authenticated Edge Function path.
How long does live interview result polling run?
The current client makes up to 40 result requests and waits three seconds after every non-completed response, including the final one. That creates 120 seconds of scheduled delay plus request time. Browser scheduling can extend the wall-clock duration, so it is not an exact two-minute deadline.
What causes a missing voice session ID?
The session ID reaches the component through a trusted iframe message, not the start response. It can remain missing when the provider message lacks a string ID, comes from a different origin, has an invalid shape, or is lost after a reload. Inspect trusted event evidence before attempting recovery.
What does voice interview score could not load mean?
That message means a result invocation threw inside the polling loop. It can reflect authentication, connectivity, Edge Function, or upstream provider failure, but the UI does not distinguish them. Inspect the request status, response body, and matching function log, then retry the same session after fixing the failed layer.
Does QAJobFit currently use a webhook for voice scores?
No webhook branch appears in the current interview voice function. The shipped client receives trusted browser events and polls the result action. A future callback would need provider-specific authentication, validated payloads, ownership controls, and idempotent persistence while keeping polling available as a recovery path.
Can retrying create duplicate interview result rows?
Repeated result requests can reach the completion branch, but the function first checks for an existing voice session ID. A unique partial database index provides the final duplicate guard. Concurrent inserts can still produce one logged uniqueness error, while the database retains only one row for that provider session.
Should I start a new interview when scoring times out?
Do not start another interview until the original session is proven unrecoverable. A new start may reserve another allowance while the provider continues scoring the first session. Preserve the ID, inspect pending or error responses, verify persistence, and follow the product's allowance policy if authorized recovery cannot find the result.
Related Guides
- QA Voice Interview Preflight Checklist
- 500+ QA and Manual Testing Interview Questions and Answers (2026)
- Accenture QA Engineer Interview Questions and Process (2026)
- Accenture SDET Interview Questions and Preparation
- Accessibility Testing Interview Questions and Answers (2026)
- Adobe QA Engineer Interview Questions and Process (2026)