Handling the Exam Session Lifecycle: Tab Close, Refresh, and Network Drop

A closed tab, a refresh, or a dropped network does not fragment one exam into several reports. The SDK flushes its event queue on pagehide, keeps the attempt alive, and rejoining with the same stable attempt_id returns resumed: true with the same session_id. A tab close stops the heartbeat, which the report records as a liveness gap rather than losing the attempt, and a network drop is absorbed by an offline-tolerant queue that flushes when the connection returns. Everything below is grounded in the ProctorLink API reference.

Schedule a Demo

Why the exam session lifecycle needs handling at all

A real exam does not run in a single uninterrupted page view. Candidates refresh, close the tab by accident, lose Wi-Fi, hit the back button, or sit an exam that outlasts its session token. Each of those could, if handled badly, split one attempt into several partial reports, each billed separately and each missing part of the story. ProctorLink is built so that the common interruptions rejoin the same attempt instead. The one design rule you own is a stable attempt_id; the SDK and the server handle the rest.

import { ProctorLink } from '@proctorlink/sdk';

// attempt_id must be STABLE for the attempt. Derive it from your own
// attempt record, never from a random value generated on page load.
const attemptId = exam.attemptId;

// Mint on your backend. If this attempt already has an active session,
// the response comes back with resumed: true and the SAME session_id,
// so a refresh or a reopened tab rejoins instead of starting over.
const { session_id, session_jwt } = await mintSession({
  attempt_id: attemptId,
});

let session = ProctorLink.createSession({
  jwt: session_jwt,   // short-lived, origin-bound session token
  sessionId: session_id,
});

await session.start();   // rejoins the same attempt if one was active

If you are integrating from scratch, start with how to add proctoring to a web application for the full mint-start-report loop, then use this page for the edge cases that loop has to survive. For the wider choice between an SDK, a REST API, and an LMS plugin, see proctoring SDK vs API vs LMS plugin.

What happens when a candidate refreshes or closes the tab?

On a full page navigation or a refresh, the SDK flushes pending data on pagehide and deliberately does not end the session. You do not need any special teardown. When the page loads again, you mint with the same attempt_id and call start() exactly as you did the first time, and the SDK rejoins the attempt already running.

A tab or browser close is the same path, except the candidate may not come back. The heartbeat the SDK sends on a fixed cadence stops, and that silence becomes a gap in the liveness record. A backgrounded tab (a tab switch rather than a close) fires a tab.hidden event, with tab.visible on return; tab.hidden is one of the events that deducts points, while tab.visible is recorded but not scored. For the full picture of what a browser tab can and cannot observe here, read what browser-based proctoring can and cannot detect.

How does resume by attempt_id work?

Resume is the mechanism behind all of this. When you mint a session with an attempt_id that already has an active session, ProctorLink does not create a second one. It returns the existing session_id with a fresh token and resumed: true.

// POST /v1/sessions with the same attempt_id while a session is still active:
{
  "session_id": "6a7b18df70e4f8ecf2597b6f",
  "session_jwt": "eyJhbGciOi...",
  "expires_at": 1786443201,
  "resumed": true
}

So a candidate who refreshes, reconnects, or reopens the exam continues the same proctoring record instead of splitting into two partial ones, and you are billed once for the attempt rather than twice. For this to work, attempt_id must be stable for the attempt: derive it from your own attempt record, not from a random value generated on page load, or every refresh starts a new session. This is the same identity split explained in why proctoring runs in a cross-origin iframe: your backend holds the API key and mints, the browser only ever receives a short-lived token for one attempt.

What about a network drop mid-exam?

Events recorded while the connection is down are not lost. They are held in an offline-tolerant queue in the browser and flushed as soon as connectivity returns. You do not have to detect the drop or retry anything yourself for the queue to work.

A long enough outage can outlast the session token. If that happens the SDK raises token-expired rather than failing silently, because without a valid token it cannot deliver data. Re-mint for the same attempt_id, which resumes, and hand the new token back with updateToken; the queued events flush immediately with it. The dead time still appears in the report as a liveness gap under the liveness_gap reason, because the server genuinely could not observe the candidate during it. That is supervision coverage, not an accusation.

How do I handle token expiry during a long exam?

The session token is short-lived, 2 hours by default. The clean approach is to avoid expiry altogether: set ttl_seconds at mint to your exam length plus a buffer, and read the returned expires_at rather than assuming your requested value was used, because out-of-range values are clamped. Keep token-expired as the safety net for exams that run longer than planned.

session.on('token-expired', async () => {
  // Re-mint for the SAME attempt_id. This resumes while the session
  // is still active, returning the same session_id with a fresh token.
  const res = await mintSession({ attempt_id: attemptId });

  if (res.resumed) {
    session.updateToken(res.session_jwt);   // same session — carry on,
                                            // queued events flush now
  } else {
    // The old session was already closed (e.g. the 3-hour sweep after a
    // long outage). This is a new attempt with its own report.
    session.destroy();
    session = ProctorLink.createSession({
      jwt: res.session_jwt,
      sessionId: res.session_id,
    });
    await session.start();
  }
});

The important branch is resumed. A re-mint only resumes while the session is still active. If the session has already ended, most likely the 3-hour abandonment sweep closed it after a long outage, the mint creates a new session, and its token belongs to that one. Passing it to updateToken would make every upload fail, so check resumed and start a fresh session deliberately when it is absent. Do not call stop() or destroy() and start a new session just to refresh a token; that ends the attempt server-side and the next mint creates a separate report, billed twice.

Leaving the exam route in a single-page app

A single-page app has an interruption a full page never does: the candidate navigates off the exam route, through a back button, a link, or a modal, without the page ever unloading. The camera preview is attached to document.body, outside your router outlet, so it survives the route change and keeps capturing, frames of an empty chair and whatever the candidate does next. Those frames are scored, and every one with no face counts toward no_face. Left unhandled, a candidate is marked down for using your own back button.

// Single-page app: the candidate navigates off the exam route
// (back button, a link, a modal) without the page unloading.
// Leaving the route is NOT finishing the exam.
session.destroy({ endSession: false });

// On return, mint with the SAME attempt_id (resumes) and start again.

Tearing down with endSession: false releases the camera, removes the preview, and flushes queued data, but keeps the session active so you can resume it. When the candidate returns, mint with the same attempt_id (the response has resumed: true) and call start() to rejoin. The identity reference and every frame recorded earlier survive, and one attempt still produces one report. This is exactly how the framework guides wire it up, for example teardown in a React effect cleanup or an Angular ngOnDestroy; see adding proctoring to a React app and adding proctoring to an Angular exam portal.

When you only want to stop capturing for a scheduled break or a modal, but keep the SDK mounted, pause() is the lighter alternative. It releases the camera and stops scoring until you call resume(), and it records session.paused and session.resumed so the gap in the frame timeline reads as a deliberate pause rather than interference.

// A scheduled break or a modal over the exam. You choose the trigger —
// the SDK never pauses itself.
session.pause();    // camera released, nothing captured, nothing scored
session.resume();   // camera back, capture continues

Lifecycle events and how to handle each one

Every interruption maps to a specific response. The table below is the short version of the whole page: what the SDK does on its own, what you have to do, and how the event shows up in the report a reviewer reads.

ScenarioWhat the SDK doesWhat you doIn the report
Full page refresh / reloadFlushes queued data on pagehide. Does not end the session.Mint again with the same attempt_id on reload, then start().Same session_id; at most a short liveness gap if the reload was slow.
Tab or browser closed mid-examHeartbeat stops; last data flushed on pagehide.Nothing immediate. The candidate rejoins, or the 3-hour sweep closes it.tab.hidden / page.unload events and a liveness gap under liveness_gap.
Network drop mid-examEvents queue locally and flush when connectivity returns.Nothing for the queue. If the token expired too, handle token-expired.A liveness gap for the dead time (longest_gap_seconds).
SPA route change (no page unload)Preview survives the route change and keeps capturing unless torn down.Call destroy({ endSession: false }) on route-leave; rejoin on return.Avoids frames of an empty chair being scored as no_face.
Session token expiresRaises token-expired; capture pauses but events keep queueing.Re-mint same attempt_id, check resumed, call updateToken.Queued events flush with the new token; nothing from the gap is lost.
Deliberate break or modalNothing until you call it — the SDK never pauses itself.pause(), then resume() when the break ends.session.paused / session.resumed mark the gap; neither is scored.

How a closed or abandoned session is finally resolved

A session does not stay open forever waiting for a candidate who closed their laptop. One with no explicit stop() is closed automatically after 3 hours of silence and then analysed like any finished attempt. Until then it stays active and can be resumed. Because face analysis is deferred rather than real-time, a report fetched the instant an exam ends will not yet contain face results. Fetch it when status is validated (equivalently, when integrity.analysis_complete is true), polling on a sensible interval rather than in a tight loop.

statusMeaning
activeAttempt in progress. Re-minting with the same attempt_id resumes it.
endedAttempt finished (stop() or the 3-hour sweep); queued for analysis.
validatingFace analysis running.
validatedAnalysis complete. This is the final report — fetch it now.
failedAnalysis could not complete. Browser activity is still valid.

A gap in supervision, whether from a tab close, a sleeping machine, a network drop, or a route-change teardown, deducts points under liveness_gap and can flag a session on its own. That is deliberate: the measure is how much of the attempt could be observed, not what the candidate intended. A score alone should never fail a candidate; use the reasons and the evidence frames for human review of anything flagged.

Common mistakes with the session lifecycle

  • Generating a new attempt_id on every page load. A random id per load means every refresh mints a brand-new session, so one exam fragments into several partial reports and is billed several times. Fix: derive attempt_id from your own attempt record so it is stable across reloads.
  • Using stop() or a bare destroy() for an SPA route change. Both end the attempt server-side, so returning to the exam mints a separate session with its own report and its own billed credit. Fix: tear down with destroy({ endSession: false }) and rejoin with the same attempt_id.
  • Calling updateToken without checking resumed. If the old session already ended, the re-mint returns a token for a new session; applying it to the old one makes every upload fail. Fix: branch on resumed and start a fresh session when it is absent.
  • Refreshing a token by tearing down and re-creating the session. Calling stop()/destroy() and starting again does not refresh a token, it ends the attempt. Fix: handle token-expired and call updateToken, which keeps the same attempt running.
  • Reading the report the instant the exam ends. Face analysis is deferred, so an immediate fetch shows zeroed counts and identity_match: pending, which is an unfinished result, not a clean one. Fix: fetch when status is validated.

Why this holds up

The pieces reinforce one another. A stable attempt_id makes refresh, reconnect, and reopen all rejoin the same record. The offline-tolerant queue absorbs network drops without losing evidence. token-expired plus a resumed check keeps a long exam on one report. And destroy({ endSession: false }) stops an SPA route change from scoring an empty chair. None of it requires you to watch for interruptions yourself; you set one stable identifier and respond to the two events the SDK raises.

If some of your exams run inside Moodle rather than your own engine, you do not wire any of this by hand; the drop-in plugin handles the attempt lifecycle for you, covered in best Moodle proctoring plugin. Across published deployments, ProctorLink has supported more than one million proctored exam sessions (methodology note below). Full cohort sizes and outcomes are on the case studies page.

What customers say on G2

Institutions evaluating proctoring tools often look for independent feedback outside vendor case studies. ProctorLink is listed on G2, where Moodle administrators and training teams share verified product reviews.

Read ProctorLink reviews on G2 →

Frequently Asked Questions

No. Closing the tab stops the heartbeat stream, and the report records that as a liveness gap rather than ending the attempt. The SDK flushes queued data on pagehide, so the events up to that point are not lost. The attempt stays active: if the candidate reopens the exam and you mint with the same attempt_id, they rejoin the same session with resumed set to true. If they never return, the session is closed automatically after 3 hours of silence and analysed like any finished attempt.

Sources & references

Deployment statistics and product behaviour described in this guide link to the sources below.

Next steps

Want to watch a refresh and a reconnect rejoin the same attempt? Sign up at app.proctorlink.com to get your API keys, mint a session, and run one end to end.

More Proctoring Guides

Keep One Exam on One Report

Set a stable attempt_id, respond to token-expired, and tear down route changes with endSession false. A refresh, a reconnect, or a reopened tab rejoins the same attempt. Run a real session before you commit.