redis/node-redis · error · Error

FT.CURSOR: unknown cursor

Error message

FT.CURSOR: unknown cursor ${token} on index "${argToString(redisArgs[2])}". Cluster cursors are minted per client instance and expire when idle — the cursor was not created by this client, has already been exhausted, or has expired.

What it means

Thrown when an FT.CURSOR READ or FT.CURSOR DEL token does not match any binding in this client's cursor map. Cluster cursors are client-minted virtual tokens bound per-client-instance: the token was either never created by this client, already exhausted (cursor reached 0 and the binding was evicted), or expired due to idle timeout (MAXIDLE).

Solutions

  1. Re-run FT.AGGREGATE ...WITHCURSOR to obtain a new cursor token
  2. Keep all FT.CURSOR READ calls within the same client instance — do not share tokens across connections or processes
  3. Track cursor exhaustion in application code: when a READ returns cursor=0, stop calling READ on that token
  4. Reduce idle time between READ calls to stay within MAXIDLE, or set MAXIDLE 0 for no idle limit

Example fix

// before — reusing an exhausted cursor
const result = await cluster.ft.aggregate('myIdx', '*', { WITHCURSOR: true });
const page1 = await cluster.ft.cursorRead('myIdx', result.cursor);
// page1.cursor === 0 means exhausted, binding evicted
const page2 = await cluster.ft.cursorRead('myIdx', result.cursor); // throws: unknown cursor

// after — check for exhaustion before re-reading
const result = await cluster.ft.aggregate('myIdx', '*', { WITHCURSOR: true });
let cursor = result.cursor;
while (true) {
  const page = await cluster.ft.cursorRead('myIdx', cursor);
  // process page.results ...
  if (page.cursor === 0) break; // exhausted — stop
  cursor = page.cursor;
}
Defensive patterns

Strategy: try-catch

Type guard

function isUnknownCursor(err: unknown): boolean {
  return err instanceof Error && err.message.includes('unknown cursor');
}

Try / catch

try {
  await cluster.ft.cursorRead('myIdx', cursor);
} catch (err) {
  if (err instanceof Error && err.message.includes('unknown cursor')) {
    // cursor expired or was exhausted — re-run the aggregate
    const result = await cluster.ft.aggregate('myIdx', '*', { WITHCURSOR: true });
  } else {
    throw err;
  }
}

Prevention

When it happens

Trigger: Using a cursor token from a different client instance (e.g. after reconnect, or from a different process); re-reading a cursor whose previous READ returned cursor=0 (exhausted); idle period between READ calls exceeded the MAXIDLE TTL; passing a raw server cursor id instead of the client-minted token.

Common situations: Cursor serialized and deserialized across process restarts; long pause between paginated reads; cursor shared between multiple worker processes; cursor reused after the final page was already returned.

Related errors


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

Appendix: source

Thrown at packages/client/lib/cluster/request-response-policies/ft-cursor.ts:111

  // server returns its own arity error instead of a client-side TypeError.
  if (redisArgs.length < 4) {
    return [{ client: await slots.nodeClient(slots.getRandomNode()) }];
  }

  const token = argToString(redisArgs[3]);

  const binding = slots.lookupCursor(token);
  if (binding) {
    const client = await slots.getMasterByAddress(binding.address);
    if (client) return [{ client, parser: withCursorArg(parser, binding.cursorId) }];

    throw new Error(
      `FT.CURSOR: the node serving cursor ${token} on index "${argToString(redisArgs[2])}" ` +
      `has left the cluster.`
    );
  }

  throw new Error(
    `FT.CURSOR: unknown cursor ${token} on index "${argToString(redisArgs[2])}". ` +
    `Cluster cursors are minted per client instance and expire when idle — ` +
    `the cursor was not created by this client, has already been exhausted, ` +
    `or has expired.`
  );
};

/** Copy of the FT.CURSOR parser with the cursor argument (index 3) replaced. */
function withCursorArg(parser: CommandParser, cursorId: string): CommandParser {
  const sub = new BasicCommandParser();
  const { redisArgs } = parser;
  for (let i = 0; i < redisArgs.length; i++) {
    sub.push(i === 3 ? cursorId : redisArgs[i] as RedisArgument);
  }
  return sub;
}

/**

View on GitHub (pinned to 90fd0652bc)