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
- Re-run FT.AGGREGATE ...WITHCURSOR to obtain a new cursor token
- Keep all FT.CURSOR READ calls within the same client instance — do not share tokens across connections or processes
- Track cursor exhaustion in application code: when a READ returns cursor=0, stop calling READ on that token
- 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
- Track cursor exhaustion in application code — stop reading when cursor returns 0
- Keep all FT.CURSOR READ/DEL calls on the same client instance that created the cursor
- Never serialize or share cursor tokens across processes or client instances
- Set MAXIDLE to 0 for no idle timeout, or keep reads frequent enough to stay within the TTL
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
- FT.CURSOR: the node serving cursor
- Cluster already open
- Cluster closed
- SCAN: unknown cursor
- The client is closed
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)