Refresh token flight deduplication in Next.js

February 4, 2026

Kazi Ehsan Aziz

Our backend authentication server rotates refresh tokens. Presenting one returns a new access token and a new refresh token, and invalidates the refresh token you just presented. Present that old one a second time and you get a 401 — as far as the backend is concerned, it no longer exists.


The Next.js app in front of it keeps both tokens in HTTP-only cookies and refreshes them in middleware.ts. The session cookie is deliberately set to expire a minute before the JWT inside it does, so "no session cookie" simply means "time to refresh", and the middleware does that before handing the request on.


Server Actions pass through the middleware too. An action is a POST like any other request, and refreshing it in place is exactly what you want: the user clicked something, their access token had just expired, and the action gets to continue on a fresh token instead of failing.


The race shows up when the app fires two Server Actions at the same time — two components mounting in the same commit, each loading its own data. Two POSTs, two middleware invocations. If they land in the window where the session cookie has expired but the refresh cookie has not, both read the same refresh token and both spend it. One wins. The other comes back 401 for a refresh token that no longer exists, writes no cookies, and the Server Action riding on it finds no session and redirects the user to /login. Someone who did nothing but open a page gets signed out, unreproducibly, because the window moves with every login.


Like most engineering problems, this one can be solved in more than one place. The backend could keep a spent refresh token usable for a few extra seconds and return the pair (access and new refresh) it just minted instead of minting another, which makes a duplicate refresh idempotent — probably the better fix when you own both sides. But when the backend cannot be touched, the frontend can solve it alone: make the second caller wait for the first caller's refresh instead of starting its own. That is what this article explores.


The middleware that only refreshes

Our middleware has one job. It does not check whether a JWT is valid, it does not decide who may see what, and it does not redirect anyone. Those decisions belong to individual page.tsx handlers and to Server Actions, each of which runs a session check at the top and redirects from there. So the middleware stays this small:

middleware.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { acquireRefreshLock } from "@/lib/refresh-dedupe";

// This middleware does not check validity of JWTs, that's done
// by individual components. The middleware only refreshes tokens.
export async function middleware() {
  // 1. Get the session from the cookie
  const accessCookie = (await cookies()).get("wwm_session")?.value;
  // cookies are always expired 1 minute before JWT expires
  const isAuthenticated = !!accessCookie;

  // 2. Refresh if refresh token is present
  if (!isAuthenticated) {
    const refreshCookie = (await cookies()).get("wwm_refresh_token")?.value;
    if (refreshCookie) {
      await acquireRefreshLock(refreshCookie);
    }
  }

  return NextResponse.next();
}

The presence of the cookie is the authentication check — no decryption, no expiry comparison. That works because of the 60-second head start: a cookie that arrives carries a token with at least a minute of life left in it, so "cookie exists" and "token is usable" are the same statement.


Everything else is in acquireRefreshLock.


Deduplicating the refresh

lib/refresh-dedupe.ts
import "server-only";
import { refreshToken } from "@/lib/backend/auth";
import { createSession } from "@/lib/session";

// In-memory Map to store ongoing refresh operations
// Key: refresh token value, Value: Promise that resolves when refresh completes
const refreshLocks = new Map<string, Promise<void>>();

/**
 * Deduplicates a token refresh across concurrent requests.
 * If a refresh is already in progress for the given refresh token,
 * returns the existing promise so concurrent requests wait for the same refresh.
 * Otherwise, creates a new refresh operation.
 */
export async function acquireRefreshLock(
  refreshTokenValue: string,
): Promise<void> {
  // Check if a refresh is already in progress for this token
  const existingLock = refreshLocks.get(refreshTokenValue);
  if (existingLock) {
    console.log("refresh-dedupe: waiting for existing refresh to complete");
    return existingLock;
  }

  // Create a new refresh promise
  const refreshPromise = (async () => {
    try {
      const result = await refreshToken(refreshTokenValue);
      if (result.success && result.data) {
        await createSession(
          result.data.tokens.access.token,
          result.data.tokens.refresh.token,
        );
      }
      return;
    } catch (error) {
      console.error("refresh-dedupe failed:", error);
    } finally {
      // Clean up the lock after a short delay to allow concurrent
      // requests to read the result
      setTimeout(() => {
        refreshLocks.delete(refreshTokenValue);
      }, 100);
    }
  })();

  // Store the promise in the map
  refreshLocks.set(refreshTokenValue, refreshPromise);

  return refreshPromise;
}

The refreshLocks map stores a promise, and a late arrival is handed the same promise the first caller is already awaiting. When it settles, every waiter resumes at once. There is no queue, no acquire/release pair to get wrong, and no way to deadlock, because the only thing anyone ever does with the lock is await it.


The other half of why it is this simple: the async IIFE is invoked before refreshLocks.set, but JavaScript runs it synchronously up to its first await. Between the get at the top of the function and the set at the bottom, no other request can interleave. Single-threaded execution gives you the atomicity for free, which is the whole reason a pattern this small is safe at all.


Why the key is the refresh token

Keying by user id would seem more natural, but the token value is the better key on two counts.



Why it resolves with nothing

acquireRefreshLock returns Promise<void>. It could have resolved with the new tokens, and deliberately does not: the refresh's real output is the cookies createSession wrote.


Why the lock is released late

Think about a third request that read its cookies while the refresh was still in flight and reaches acquireRefreshLock a millisecond after it finished. Its refreshTokenValue is the old, now-spent token — that is simply what its request carried. With an immediate delete it finds no lock, starts a fresh refresh with a dead token, and lands us back in the 401 we removed. The delay keeps the spent token's entry around long enough to absorb those stragglers: they join a promise that has already resolved, resume immediately, and read the cookies the winner set.


One process only

refreshLocks is a module-level Map, so it lives in the memory of whichever process is running the middleware. Deduplication therefore only holds for requests that land on the same instance. That is fine for a single instance and not fine on a platform that runs middleware in many isolated ones. For multiple instances of your frontend running, the map has to live in an external storage like Redis.