redis/node-redis · error · Error

SCAN: unknown cursor

Error message

SCAN: unknown cursor "${cursorArg}". Cluster-wide SCAN cursors are minted per client instance and expire when idle — restart the scan from 0.

What it means

The cursor token passed to a cluster-wide SCAN does not match any client-minted cursor. In cluster mode, SCAN cursors are opaque virtual tokens created per client instance; they expire when idle and are not shared across instances. A stale or foreign cursor cannot be resolved to a (node, real-cursor) pair.

Solutions

  1. Restart the scan from cursor '0' when this error is caught
  2. Avoid long delays between SCAN pages — process pages promptly
  3. Increase SCAN COUNT to reduce round trips and complete faster
  4. Do not persist cursors across application restarts or share across client instances
Defensive patterns

Strategy: retry

Try / catch

async function scanFromZero(client, fn) {
  for (;;) {
    try {
      let cursor = '0';
      do {
        const reply = await client.scan(cursor);
        await fn(reply.keys);
        cursor = reply.cursor;
      } while (cursor !== '0');
      return;
    } catch (e) {
      if (e.message.startsWith('SCAN: unknown cursor')) {
        continue; // restart from 0
      }
      throw e;
    }
  }
}

Prevention

When it happens

Trigger: Passing a cursor from a previous SCAN that has expired, a cursor from a different client instance, or a cursor after the client reconnected and lost its cursor state.

Common situations: Long pause between SCAN pages exceeding cursor expiry; reusing a cursor from a previous application run; client reconnected (slots refreshed) and internal cursor store was cleared; load balancer routing requests to different client instances.

Related errors


AI-assisted analysis of redis/node-redis@90fd0652bc (2026-08-11). Data as JSON: /api/errors/621e33d293793041. Report an issue: GitHub.

Appendix: source

Thrown at packages/client/lib/cluster/request-response-policies/scan-cursor.ts:46

 */
export const routeScan: RequestRouter = async (slots, parser) => {
  // Malformed raw command (missing cursor): forward to any node so the server
  // returns its own arity error instead of a client-side TypeError.
  if (parser.redisArgs.length < 2) {
    return [{ client: await slots.nodeClient(slots.getRandomNode()) }];
  }

  const cursorArg = argToString(parser.redisArgs[1]);

  if (cursorArg === '0') {
    const address = slots.nextScanTarget(EMPTY_VISITED);
    if (!address) throw new Error('SCAN: no master nodes available');
    return [{ client: await pinnedMaster(slots, address) }];
  }

  const entry = slots.lookupScanCursor(cursorArg);
  if (!entry) {
    throw new Error(
      `SCAN: unknown cursor "${cursorArg}". Cluster-wide SCAN cursors are ` +
      `minted per client instance and expire when idle — restart the scan from 0.`
    );
  }
  return [{
    client: await pinnedMaster(slots, entry.address),
    parser: withCursor(parser, entry.cursor)
  }];
};

const EMPTY_VISITED: ReadonlySet<string> = new Set();

async function pinnedMaster(slots: ClusterSlots, address: string) {
  const client = await slots.getMasterByAddress(address);
  if (!client) {
    throw new Error(
      `SCAN: node ${address} serving this cursor has left the cluster — ` +
      `restart the scan from 0.`

View on GitHub (pinned to 90fd0652bc)