Skip to content

fix(client): stop the user-token refresh from spinning - #1062

Open
adrewh wants to merge 1 commit into
knocklabs:mainfrom
adrewh:fix/user-token-refresh-loop
Open

fix(client): stop the user-token refresh from spinning#1062
adrewh wants to merge 1 commit into
knocklabs:mainfrom
adrewh:fix/user-token-refresh-loop

Conversation

@adrewh

@adrewh adrewh commented Aug 4, 2026

Copy link
Copy Markdown

Summary

Knock.maybeScheduleUserTokenExpiration schedules its next refresh as exp - timeBeforeExpirationInMs - now and passes the result straight to setTimeout:

const msInFuture = expiresAtMs - timeBeforeExpirationInMs - nowMs;
const timerId = setTimeout(async () => { ... }, msInFuture);

Whenever the client authenticates with a token that is already inside the refresh window, that value is negative, setTimeout treats it as 0, and the refresh callback runs on the next tick. If the token that comes back is also inside the window, the callback re-authenticates and the instance spins as fast as the app's token endpoint can answer.

Spinning is expensive rather than merely wasteful. authenticate() sees changed credentials on each iteration, so each one runs feeds.teardownInstances() then teardown() (socket disconnect), createApiClient(), feeds.reinitializeInstances(), plus an inline PUT /v1/users/:id when identification is inline. Reproduced against this package under happy-dom with tokens that sit inside the refresh window (inert WebSocket stub counting opens):

refresh callbacks websocket opens
before 6,096 6,096
after 1 1

in 10 seconds. In a browser that is a flood of 429s from api.knock.app and repeated WebSocket is closed before the connection is established as sockets are torn down mid-handshake. Node also surfaces the underlying mistake directly:

TimeoutNegativeWarning: -3448 is a negative number. Timeout duration was set to 1.
    at maybeScheduleUserTokenExpiration (@knocklabs/client/dist/esm/knock.mjs:133)
    at authenticate (.../knock.mjs:54)

We hit this in production behind a React integration: our onUserTokenExpiring handler mints a fresh 1h token every time and is nonetheless called ~6 times per second, sustained, from a single tab.

Reaching the bad state does not require anything exotic: a token whose TTL is shorter than timeBeforeExpirationInMs, a stale near-expiry token re-supplied by a re-render (useAuthenticatedKnockClient re-authenticates from the userToken prop, which an app that rotates in place never updates), a browser clock running ahead of the token issuer's, or a laptop resuming from sleep with a timer that should already have fired.

Changes

  • Floor the delay at 1s (MIN_TOKEN_EXPIRATION_DELAY_MS) so a token inside the refresh window schedules a real wait instead of an immediate refresh, and setTimeout never receives a negative delay.
  • Clear the previous timer before scheduling. authenticate() with unchanged credentials does not tear down, so overwriting tokenExpirationTimer left the old timer live; it still woke up and called the app's refresh callback before failing the this.tokenExpirationTimer !== timerId check. A React effect re-running was enough to leak one wasted refresh call per re-auth.
  • Refuse a refresh that makes no progress. If the new token expires no later than the token it replaces, or too soon to schedule another genuine wait, log and stop instead of re-authenticating. That is the change that makes the loop structurally impossible rather than merely 1000x slower; the socket then stays in its normal reconnect backoff.

Tokens with no readable exp keep their current behaviour (treated as progress) since they schedule nothing and so cannot spin.

Tests

Four tests added to test/knock.test.ts, in the existing JWT Token Expiration block and style:

  • floors the refresh delay for a token already inside the expiration window
  • does not re-authenticate when the refreshed token expires no later than the current one
  • does not spin when the refreshed token is also inside the expiration window
  • keeps a single refresh timer when re-authenticating with unchanged credentials

yarn test is green across the monorepo (84 files, 1,015 tests), and yarn type:check, yarn lint, and yarn format:check pass for @knocklabs/client. Patch changeset included.

Notes

Happy to adjust the shape of this: the floor constant, whether the no-progress case should surface through something other than log(..., true), or whether refusing to re-authenticate should be opt-in. The one behaviour I would argue for keeping either way is never scheduling a non-positive delay.

`maybeScheduleUserTokenExpiration` computes its delay as
`exp - timeBeforeExpirationInMs - now` and passes it straight to `setTimeout`.
Whenever the client authenticates with a token that is already inside the
refresh window that value is negative, `setTimeout` treats it as `0`, and the
refresh runs on the next tick. If the refreshed token is also inside the window
(a short-lived token, a stale token re-supplied by a re-render, a client whose
clock runs ahead of the token issuer) the callback re-authenticates and the
instance spins.

Spinning is expensive rather than merely wasteful: `authenticate()` sees changed
credentials, so every iteration tears down the API client and its socket,
rebuilds them, re-identifies the user, and rejoins feed channels. Reproduced
against this package with near-expiry tokens: 6,096 refreshes and 6,096
websocket opens in 10 seconds, which in a browser means a flood of 429s from
`api.knock.app` and repeated "WebSocket is closed before the connection is
established".

Three changes:

- Floor the timer delay at 1s so a token inside the refresh window schedules a
  real wait instead of an immediate refresh.
- Clear the previous timer before scheduling. `authenticate()` with unchanged
  credentials does not tear down, so overwriting `tokenExpirationTimer` left the
  old timer live: it still woke up and called the app's refresh callback before
  failing the identity check.
- Don't re-authenticate when the refreshed token expires no later than the token
  it replaces, or too soon to schedule another genuine wait. Log and stop, which
  leaves the socket in its normal reconnect backoff.

Tokens with no readable expiration keep their current behaviour: they schedule
nothing, so they cannot spin.
@changeset-bot

changeset-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0b7a7da

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@knocklabs/client Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

@adrewh is attempting to deploy a commit to the Knock Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant