OrderCloud Token Refresh in Next.js: A Production Pattern for SSR Storefronts

OrderCloud project I’ve worked on ships its first version with a token refresh bug. It rarely shows up in dev. It doesn’t show up in QA. It shows up in production — three weeks after launch, on a real device, when two requests happen to refresh at the same moment and one of them gets randomly logged out. The support ticket reads: “I was just browsing and it threw me to the login page.”

It’s not really an auth bug. It’s a coordination problem dressed up as one. And the fix isn’t OrderCloud-specific — the same shape applies to Auth0, Cognito, or any short-lived OAuth token used from a server-rendered React app.

Why this happens, even with long-lived tokens

OrderCloud’s refresh token workflow is opt-in: you have to set ApiClient.RefreshTokenDuration to a number greater than zero. Once enabled, every auth response includes a refresh_token you can exchange for a new access_token. The exchange issues a new refresh token — the old one is no longer valid.

Access tokens last for hours (expires_in in the auth response — typically several hours for password/client-credentials flows, shorter for anonymous). So the obvious instinct is “this race is rare.” It isn’t. The race fires whenever:

  • Multiple requests are in flight at the moment the token expires (SSR + client components + route handlers all using the SDK)
  • A user leaves a tab open across the expiry boundary and several background fetches resume at once
  • Anonymous tokens — shorter-lived — refresh on a busy listing page

Five callers hit Auth.RefreshToken with the same now-rotated refresh token. The first one succeeds and gets a new pair. The other four fail or use a refresh token the API has already invalidated. Their subsequent refreshes fail too. The session dies.

What we shipped: client-side throttle + SDK-managed cookies

In our project — a Next.js + XM Cloud storefront on ordercloud-javascript-sdk@11.1.4 — we coalesce refresh attempts with a lodash throttle, leaning on the SDK’s built-in cookie storage as the shared token state.

Here’s the shape of our requestDecorator.ts, sanitized:

import { Auth, Tokens } from "ordercloud-javascript-sdk";
import { throttle } from "lodash";
async function tryRefreshExpiredTokens(clientID: string) {
const refreshToken = Tokens.GetRefreshToken();
if (refreshToken) {
const { access_token } = await Auth.RefreshToken(refreshToken, clientID);
Tokens.SetAccessToken(access_token);
return;
}
// No refresh token (session expired) → fall back to anonymous
const { access_token } = await Auth.Anonymous(clientID);
Tokens.SetAccessToken(access_token);
}
export const throttledTryRefreshExpiredTokens = throttle(
tryRefreshExpiredTokens,
30 * 1000,
{ trailing: false }
);

Two things to notice:

  1. trailing: false — within any 30-second window, only the first call does work; the rest return without firing. We don’t queue them, we don’t await them. That’s intentional, because…
  2. The SDK is the shared state. When the first call finishes, Tokens.SetAccessToken updates the cookie that Configuration.Set({ cookieOptions }) registered at app boot. Every subsequent OrderCloud SDK request reads the cookie at call time and picks up the fresh token. The other four callers don’t need the refresh result returned — they read it from cookie state on their next attempt.

The anonymous fallback is the other half: if the refresh token itself is missing or rejected, we don’t bounce the user to login — we silently establish an anonymous session so guest browsing and the cart-merge flow keep working.

The alternative: promise coalescing

If your project needs each caller to receive the resolved token (not just rely on side-effect cookie state), use a promise-coalescing pattern instead:

let refreshPromise: Promise<string> | null = null;
export async function getValidToken(): Promise<string> {
const current = readCookie("oc.access");
if (current && !isExpiring(current)) return current;
if (!refreshPromise) {
refreshPromise = doRefresh().finally(() => { refreshPromise = null; });
}
return refreshPromise;
}

Same coordination idea, different shape. Every caller awaits the same in-flight promise; one network call, N readers. This is the right pattern when you’re not using a token-aware SDK and need the token value returned to the caller.

For OrderCloud specifically — where the SDK already manages cookie state — the throttle approach is the lower-friction path. For Auth0, Cognito, or a custom JWT setup, promise coalescing is usually cleaner.

401 is not a retry — it’s a re-login

One last note. When refresh itself fails (revoked API client, expired refresh token, disabled account), 401 should not trigger a silent retry. We’ve seen middleware loops where 401 triggers a refresh that returns 401 that triggers another refresh — until something times out.

Our rule: 401 from a non-auth endpoint → retry once through throttledTryRefreshExpiredTokens. 401 from the refresh endpoint itself → terminal. Mark the user anonymous, clear cookies, redirect to login.

Conclusion

Auth in headless commerce is a coordination problem, not an auth problem. Tokens don’t have to be short-lived for the race to bite — they just have to occasionally expire while parallel requests are in flight. The fix is the same in every project: pick one coordination primitive (throttle, promise coalescing, distributed lock) and route every refresh attempt through it.

We chose throttle + SDK-managed cookies because the ordercloud-javascript-sdk already provides shared state via cookies. Your project may want promise coalescing if your token storage is bespoke, or a Redis lock if you’re running multiple Node instances behind a load balancer. The mechanism matters less than the rule: only one refresh in flight at a time, per user, per environment.

If you’ve shipped this differently — I’d genuinely like to hear what you learned, especially on the multi-instance edge case. Reply or DM.


References:

Happy Coding 🎉

Leave a comment

I’m Ravi

Site Logo

Digital Experience Engineer and Sitecore Full-Stack Developer with hands-on experience building scalable enterprise web applications and composable digital experience platforms using Sitecore XM Cloud, Next.js, React, TypeScript and modern headless architecture.

Let’s connect

Design a site like this with WordPress.com
Get started