---
title: 'Session Renewal'
source: 'https://academia.sh/en/courses/frontend-architecture/session-renewal'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:04+00:00'
license: 'CC BY-SA 4.0'
---

# Session Renewal

Refreshing a short-lived token without it reaching the user, gathering concurrent authorization errors into a single renewal, the refresh token's rotation and reuse detection, and sign-out taking effect everywhere.

The previous lesson presented the token's short lifetime as a defense. The cost of that short
lifetime is clear: the session dropping every fifteen minutes. If the time runs out while the
observer is filling out the measurement form, the submission comes back with a 401, and
everything they wrote is put at risk.

This lesson builds the arrangement that keeps the short lifetime without paying that cost:
refreshing without the user noticing, gathering multiple simultaneously dropped requests into
a single refresh, protecting the refresh channel itself, and the session closing everywhere
at the same time.

## Renewal's Three Triggers

**Reactive renewal** happens when a 401 response is received. The request layer sees the
response, refreshes the token, and repeats the request once. It is the simplest and most
resilient path: it relies on the server's decision, not on the client's clock.

**Proactive renewal** happens shortly before the time runs out. The expiry check from the
previous lesson serves this: the token's `exp` field is read, and it is refreshed early,
leaving an allowance. Its payoff is that the user does not wait for a request to go out twice.
It is not sufficient on its own — the client's clock might be wrong, the server might have
revoked the token early — so it does not replace reactive renewal, it works alongside it.

**Silent session establishment at startup** happens when the page refreshes. In an
arrangement that keeps the access token in memory, nothing is left on hand once the page
refreshes; the application tries the refresh channel once at startup. If it succeeds, the
session continues where it left off; if it fails, the session is considered closed. The
`unknown` identity state mentioned in the previous lesson applies for exactly the duration of
this attempt.

## Single Flight

When a screen opens, several requests usually go out at once: the station list, measurement
history, a summary, alerts. If the token has expired, all four get a 401. If each one starts
its own renewal, four renewal requests go to the server — and if the token rotates, three of
them arrive with an invalidated token, and the session closes.

The solution is the **single flight** pattern: the promise produced when renewal starts is
stored, and everyone requesting a renewal at the same time waits on that same promise.

```js
// session-renewal.mjs — silent renewal, single flight, and reuse detection
import http from "node:http";

// --- Server -------------------------------------------------------------------
let version = 1;
let currentAccess = "e1", currentRefresh = "y1";
let sessionOpen = true, renewalCount = 0;
const used = new Set();

const readBody = (request) => new Promise((resolve) => {
  let m = ""; request.on("data", (p) => (m += p)); request.on("end", () => resolve(m ? JSON.parse(m) : {}));
});

const server = http.createServer(async (request, response) => {
  const path = new URL(request.url, "http://127.0.0.1").pathname;
  const send = (status, body) => {
    response.writeHead(status, { "content-type": "application/json" });
    response.end(JSON.stringify(body));
  };

  if (path === "/measurements") {
    if (!sessionOpen || request.headers.authorization !== `Bearer ${currentAccess}`)
      return send(401, { code: "no_identity" });
    return send(200, { record: "o-114", version });
  }

  if (path === "/session/refresh") {
    const { refresh } = await readBody(request);
    if (used.has(refresh)) {                  // same token twice: revoke the family
      sessionOpen = false;
      return send(401, { code: "reuse_detected" });
    }
    if (!sessionOpen || refresh !== currentRefresh)
      return send(401, { code: "invalid_refresh" });
    used.add(refresh);
    version += 1; renewalCount += 1;
    currentAccess = `e${version}`; currentRefresh = `y${version}`;
    return send(200, { access: currentAccess, refresh: currentRefresh });
  }

  if (path === "/session/close") { sessionOpen = false; return send(204, {}); }
  send(404, { code: "not_found" });
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const BASE = `http://127.0.0.1:${server.address().port}`;

// --- Client ---------------------------------------------------------------------
const session = { access: "e0", refresh: "y1", open: true };   // access deliberately stale
let inFlight = null;

function renew(usedAccess) {
  // Someone else already renewed: this request is retried directly with the new token.
  if (usedAccess !== session.access) return Promise.resolve(true);
  if (inFlight) return inFlight;

  inFlight = (async () => {
    const response = await fetch(`${BASE}/session/refresh`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ refresh: session.refresh }),
    });
    if (!response.ok) { session.open = false; return false; }
    const body = await response.json();
    session.access = body.access;
    session.refresh = body.refresh;           // rotation: the old token is no longer valid
    return true;
  })();
  inFlight.finally(() => { inFlight = null; });
  return inFlight;
}

async function request(name) {
  if (!session.open) return `${name}: session closed`;
  const usedAccess = session.access;
  let response = await fetch(`${BASE}/measurements`,
    { headers: { authorization: `Bearer ${usedAccess}` } });
  if (response.status !== 401) return `${name}: ${response.status}`;

  const renewed = await renew(usedAccess);
  if (!renewed) return `${name}: session dropped`;

  response = await fetch(`${BASE}/measurements`,
    { headers: { authorization: `Bearer ${session.access}` } });
  const body = await response.json();
  return `${name}: ${response.status} version=${body.version ?? "-"}`;
}

// --- 1. Four requests dropping at the same instant ---------------------------
const results = await Promise.all(["station", "history", "summary", "alerts"].map(request));
results.forEach((s) => console.log("  " + s));
console.log("renewal count on the server:", renewalCount);

// --- 2. The old refresh token used a second time -----------------------------
const old = "y1";
const y = await fetch(`${BASE}/session/refresh`, {
  method: "POST", headers: { "content-type": "application/json" },
  body: JSON.stringify({ refresh: old }),
});
console.log("\nold refresh token →", y.status, JSON.stringify(await y.json()));

// --- 3. After the family has been revoked -------------------------------------
session.open = true;                           // the client does not know yet
console.log("request after revocation   →", await request("history"));
console.log("client's session state     :", session.open ? "open" : "closed");

server.close();
```

```
  station: 200 version=2
  history: 200 version=2
  summary: 200 version=2
  alerts: 200 version=2
renewal count on the server: 1

old refresh token → 401 {"code":"reuse_detected"}
request after revocation   → history: session dropped
client's session state     : closed
```

## Reading the Output

All four requests returned successfully, and the renewal counter on the server showed **one**.
Four separate call sites shared a single renewal; none of them knows the others exist.

Two guards make the gathering work, and both are required. The first is the **in-flight
promise**: a second request arriving while a renewal is underway does not start a new one, it
joins the one in progress. The second is **comparing the token that was used**: a request
that notices the 401 late does not request a renewal at all if one has already finished — it is
retried directly with the new token. With only the first guard in place, a 401 arriving right
after a renewal finishes would start an unnecessary second renewal.

The value `version=2` shows that all four requests were answered with the token from **after**
the renewal. The retry has to read the token that changed during the renewal; using the stale
value captured at the start of the request would have dropped the retry into a 401 too.

## Rotation and Reuse Detection

The refresh token is long-lived, and that makes it the most valuable part of the session. Two
mechanisms narrow the risk.

**Rotation**: on every renewal, not just the access token changes but the refresh token too.
One that is already been used is never accepted again. So a refresh token's usable lifetime
lasts only until the next renewal.

**Reuse detection**: if a used refresh token arrives a second time, the server does not treat
that as a single client's mistake. There are two possibilities — either a copy is
circulating, or the renewal response never reached the client before the connection dropped.
Since there is no way to distinguish the two, the server picks the safe side and revokes every
token tied to that session. The output's second block shows this: when `y1` was sent a
second time, the response was `reuse_detected`.

The third block shows the consequence of the revocation. The client did not know the session
had closed and sent its request; it got a 401, tried to renew, that was rejected too, and the
client marked the session as closed. So **what tells the client the session has ended is the
renewal failing.**

This mechanism has a side effect: a renewal response missed because of a network drop also
brings the session down. This is an accepted trade-off; the alternative is treating a used
token as valid for a while longer, which reduces the value of the detection.

## Loop and Queue Traps

Renewal code is exposed to two traps, and both show up in production.

**An infinite loop.** If the renewal request itself returns a 401, and that request also goes
through the same renewal mechanism, the loop never closes. The rule: the renewal request
**never**, under any condition, goes through the renewal mechanism, and is never retried on
its own 401. In the code above, the renewal call does not use the `request` function; it is
sent directly.

**A single-retry limit.** If the request, retried after a renewal, gets a 401 again, a second
renewal is not attempted. This signals that the user really does not have authorization for
that resource; mistaking an authorization error for an identity error and continuing to renew
wears down the session for nothing.

There is also a queueing decision. New requests arriving while a renewal is underway can be
handled two ways: sent with the old token, getting a 401 and joining the queue, or not sent
at all until the renewal finishes. The second produces no unnecessary requests, but requires
the application to route every request through a gate aware of the identity state. The code
above implements the first; all four requests went out, all four got a 401, and all four were
gathered into a single renewal.

## The Sign-Out Flow

Sign-out consists of three steps, and all three are required.

**Invalidation on the server.** In a cookie-based arrangement, the session record is deleted
and the cookie is rewritten with an empty value and a past expiry date. In a token-based
arrangement, the refresh token is revoked. Cleaning up only the client side is not a
sign-out; a copy of the token would still remain valid.

**Clearing the client's state.** The in-memory token, the identity state, and
**user-specific data in the cache** are cleared. The last of these is frequently skipped: if
the measurement history stays in the cache, a second observer signing in on the same browser
sees the previous user's data for a moment. The normalized store from the Data Access topic
is the target of this cleanup.

**Cross-tab synchronization.** The same user might have the application open in three tabs.
If a sign-out in one tab is not announced to the others, those tabs keep behaving as if a
session were open and get an unexpected 401 on their first request. The announcement is made
either with message passing between tabs of the same origin, or by watching for a change in
shared storage; both were introduced in the Browser and the Web Platform course. The same
mechanism works in reverse too: a session opened in one tab is treated as open in the others.

Sign-out's counterpart on the user's side also needs consideration. An unfinished form's
content is discarded on sign-out; asking the user about this is better than discarding it
silently. On a forced sign-out — when the session drops — the address the user was on is
saved while they are redirected to the login screen, and they are returned there after signing
in.

## Summary

- Renewal has three triggers: reactive after a 401, proactive before the time runs out, and
  silent session establishment at page startup. Proactive renewal does not replace reactive
  renewal.
- Concurrent authorization errors are gathered into a single-flight renewal; the in-flight
  promise and comparing the token that was used are both required together.
- A retried request must read the token that changed during the renewal; the value captured
  at the start of the request is no longer valid.
- The refresh token rotates on every use; a used token arriving a second time results in the
  whole session being revoked.
- The renewal request never goes through the renewal mechanism, and a retried request that
  gets a second 401 is not renewed again.
- Sign-out requires all three steps together: invalidation on the server, clearing the
  client's state and user-specific cache, and cross-tab synchronization.

## Next Step

The identity flow is complete: the observer is recognized, their session carries on by
itself, and sign-out takes effect everywhere. But the recognized observer also has a
language. Every piece of text produced throughout this course — error messages, the "session
dropped" notice, empty-result explanations, date and number formats — was written for a
single language. For an observer watching the station from Norway, "−4.2" is not the right
format, "2 fields need correction" is not the right sentence, and even the interface's flow
direction might differ. The final lesson builds that adaptation.
