npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

single-flight-auth

v0.1.0

Published

Single-flight access-token refresh within and across browser tabs. Fixes the "random 401" caused by refresh-token rotation races.

Readme

single-flight-auth

Single-flight access-token refresh within a tab and across tabs. Fixes the "random 401 / random logout" that refresh-token rotation causes when two tabs refresh at the same time.

npm CI bundle size license

Zero dependencies · ~2.3 kB brotli · ESM + CJS · TypeScript · works in browsers and Node.

The bug

Your API issues short-lived access tokens and rotates the refresh token on every refresh, which is the right thing to do. A user has the app open in two tabs. The access token expires; both tabs notice; both call /auth/refresh with the same refresh token. The server consumes it for the first request and rejects the second — and that tab is logged out. The user reports "I get logged out at random"; the logs show nothing but a 401. I spent a week on exactly this once.

sequenceDiagram
    participant A as Tab A
    participant B as Tab B
    participant S as Auth server
    Note over A,B: access token expires
    A->>S: POST /auth/refresh (cookie rt1)
    B->>S: POST /auth/refresh (cookie rt1)
    S-->>A: 200 · new access token · Set-Cookie rt2
    S-->>B: 401 · rt1 already used
    Note over B: logged out "at random"

single-flight-auth makes the refresh happen once: concurrent callers in a tab share one promise, and tabs take turns through the Web Locks API. The tab that gets the lock second finds the new token already in storage and skips the request.

sequenceDiagram
    participant A as Tab A
    participant B as Tab B
    participant S as Auth server
    Note over A,B: access token expires
    A->>A: acquire lock "single-flight-auth:refresh"
    B->>B: wait for the lock
    A->>S: POST /auth/refresh (cookie rt1)
    S-->>A: 200 · token 2 · Set-Cookie rt2
    A-->>B: BroadcastChannel { type: "tokens" }
    A->>A: release lock
    B->>B: lock acquired → storage already has token 2 → no request

Install

npm install single-flight-auth

Use

import { createAuthClient, SessionLostError } from 'single-flight-auth';

export const auth = createAuthClient({
  // Called at most once at a time — per tab and, with the lock, across tabs.
  refresh: async () => {
    const response = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' });
    if (!response.ok) throw response; // 401/403 → session lost; anything else → transient
    const { accessToken, expiresIn } = await response.json();
    return { accessToken, expiresAt: Date.now() + expiresIn * 1000 };
  },
  storage: 'localStorage', // share the access token between tabs (default: 'memory')
  onSessionLost: () => location.assign('/login'),
});

// Right after login:
await auth.setTokens({ accessToken, expiresAt });

// Anywhere else: attaches the token, refreshes when it is about to expire,
// and on a 401 refreshes once and retries once.
const me = await auth.fetch('/api/me');

// Or just the token, for a client that builds its own requests:
const token = await auth.getAccessToken();

What you get

  • One refresh per tab: concurrent getAccessToken() / fetch() calls share the in-flight promise.
  • One refresh across tabs: an exclusive Web Lock; a localStorage lease where Web Locks are missing; a double-check inside the lock so the second tab reuses the first tab's token.
  • Tabs stay in sync: refreshes, setTokens(), clear() and lost sessions are broadcast.
  • Proactive refresh: tokens within expirySkewMs (30 s) of expiry are refreshed before use.
  • 401 handling: auth.fetch refreshes once and retries once, resending the body; a second 401 is returned to you.
  • Session-lost semantics: a 401/403 from refresh() clears the tokens everywhere and calls onSessionLost once per tab; network errors keep the tokens and are simply rethrown.
  • Node too: in-process single flight everywhere, and Web Locks on Node 24+ — useful for service-to-service token caches.

Proof

pnpm e2e builds the library, starts a mock auth server with refresh-token rotation (test/e2e/mock-server.ts), opens five tabs in Chromium, lets the access token expire and calls the API from all five at the same moment. Output of test/e2e/race.spec.ts:

| Scenario | Refresh calls | Rejected by server | Tabs logged out | | --- | ---: | ---: | ---: | | no lock, no channel (the bug) | 5 | 4 | 4 | | Web Locks + localStorage | 1 | 0 | 0 | | localStorage lease fallback | 1 | 0 | 0 | | Web Locks + memory storage + channel | 2 | 0 | 0 | | no lock, server grace period 2 s | 5 | 0 | 0 |

The same race is also reproduced in-process in test/unit/client.test.ts ("the race, in one process") against a model of a rotating server, so it runs in under a second without a browser.

Why the server needs a grace period too

The lock removes the common race, but a rotated refresh token can still be presented twice when a tab closes after its request reached the server but before the response arrived, when a proxy or middleware serves a cached response carrying an old Set-Cookie, or when a request from before the refresh is still in flight. So, on the server:

// POST /auth/refresh — sketch
const record = await refreshTokens.find(presented);
if (!record) return unauthorized();
if (record.consumedAt) {
  const withinGrace = Date.now() - record.consumedAt < 30_000;
  if (withinGrace) return ok(record.successor);   // a raced tab: hand out the same new tokens
  await refreshTokens.revokeFamily(record);         // reuse after the window: treat as theft
  return unauthorized();
}
await refreshTokens.consume(record);                // atomically, before anything slow
return ok(await issueSuccessor(record));

The last row of the table shows a grace period alone also prevents logouts — at five refreshes instead of one. Use both. See ADR 0002.

Gotchas seen in production

  • Cached cookies. Make /auth/refresh responses Cache-Control: no-store, and make sure no middleware rewrites cookies from a cached response. A Next.js middleware that forwarded a cached Set-Cookie was half of the original week-long bug.
  • Clock skew. expiresAt is compared with Date.now() in the browser. Derive it from expiresIn (a duration) rather than from an absolute server timestamp.
  • Memory storage after reload. With storage: 'memory' a reload starts with a refresh (reason: 'missing'), which is exactly what you want when the refresh token is an httpOnly cookie.

API

createAuthClient(options)

| Option | Default | What it does | | --- | --- | --- | | refresh(context) | required | Returns { accessToken, expiresAt }. context.previous is the token set that triggered the refresh, context.reason is 'missing' \| 'expired' \| 'unauthorized' \| 'manual'. Throw a Response (or { status }) with 401/403 to signal a lost session. | | storage | 'memory' | 'memory', 'localStorage', 'sessionStorage' or a custom TokenStorage (sync or async). Web Storage falls back to memory when unavailable. | | storageKey | 'single-flight-auth:tokens' | Key for the Web Storage adapters. | | lock | 'auto' | 'auto', 'web-locks', 'lease', 'none' or a custom RefreshLock. | | lockName | 'single-flight-auth:refresh' | Name of the Web Lock / lease. Use one per user session if several clients coexist. | | channel | 'single-flight-auth' | BroadcastChannel name, a custom TokenChannel, or false. | | expirySkewMs | 30_000 | Refresh proactively when the token expires within this window. | | leaseTtlMs | 10_000 | Lease fallback only: how long a lease may be held before it counts as abandoned. | | isSessionLost(error) | 401/403 | Classifies errors thrown by refresh(). | | onSessionLost(error) | — | Called once per lost session, in every tab. | | fetch | globalThis.fetch | Implementation used by client.fetch. | | attachToken(request, token) | Authorization: Bearer | Customise how the token travels. | | retryOn401 | true | Refresh once and retry once when a response is 401. | | now() | Date.now | Clock, for tests. |

AuthClient

| Method | Behaviour | | --- | --- | | getAccessToken({ forceRefresh? }) | Fresh token, refreshing first if needed. | | getTokens() | Stored token set or null; never refreshes. | | refresh() | Forces a refresh (still single-flight and locked). | | fetch(input, init?) | fetch with the token attached and the 401-retry behaviour. | | setTokens(tokens) | Store tokens obtained elsewhere (after login) and tell other tabs. | | clear() | Forget the tokens in every tab, without calling onSessionLost. | | subscribe(listener) | Observe token changes; returns an unsubscribe function. | | dispose() | Close the channel; the client cannot be used afterwards. |

SessionLostError (with cause set to the original error) is what waiting callers receive when a refresh is rejected.

Lower-level pieces are exported for custom setups: singleFlight, memoryStorage, webStorage, broadcastChannel, webLocksLock, leaseLock, noLock, isTokenSet.

Keeping the refresh token in JavaScript

Prefer an httpOnly cookie. If you must hold the refresh token in the client, extend the token set — and know that localStorage makes it readable by any script that runs on your origin:

interface MyTokens { accessToken: string; expiresAt: number; refreshToken: string }

const auth = createAuthClient<MyTokens>({
  refresh: async ({ previous }) => post('/auth/refresh', { refreshToken: previous?.refreshToken }),
});

How it works

  1. getAccessToken() reads storage. A token that is fresh (outside the skew window) is returned.
  2. Otherwise the refresh goes through singleFlight: concurrent callers in this tab await the same promise.
  3. The refresh acquires the cross-tab lock, then re-reads storage. If another tab stored a different, fresh token meanwhile, it is returned without a request.
  4. Otherwise refresh() runs; the result is validated, stored, broadcast ({ type: 'tokens' }) and returned.
  5. If refresh() throws and isSessionLost(error) is true, storage is cleared, { type: 'session-lost' } is broadcast and every tab calls onSessionLost once. Any other error keeps the tokens and is rethrown.

Design notes live in docs/adr.

Browser and runtime support

| Capability | Where | Without it | | --- | --- | --- | | Web Locks | Chrome 69, Firefox 96, Safari 15.4, Node 24 | localStorage lease (best effort) | | BroadcastChannel | Chrome 54, Firefox 38, Safari 15.4, Node 18 | tabs only sync through shared storage | | fetch / Request | everywhere modern, Node 18 | pass your own fetch |

Development

pnpm install
pnpm check   # lint, typecheck, unit tests, build, size-limit
pnpm e2e     # Playwright race reproduction (needs: pnpm exec playwright install chromium)

License

MIT © Ilia Muravev