redis/node-redis · warning · WatchError
One (or more) of the watched keys has been changed
Error message
One (or more) of the watched keys has been changed
What it means
Thrown in _executeMulti when EXEC returns null. Redis returns null from EXEC when any WATCHed key was modified between WATCH and EXEC, meaning the transaction was aborted and no commands ran. This is normal optimistic-locking behavior, surfaced as a WatchError so callers can detect it and retry the read-modify-write cycle.
Solutions
- Treat WatchError as a retry signal: re-read the current value, re-issue WATCH, and re-run MULTI/EXEC in a bounded loop.
- Reduce contention by switching to a server-side Lua script for atomicity instead of WATCH/MULTI.
- Cap retries and back off to avoid live-lock under sustained contention.
- Confirm no other part of your own application is writing the watched key unexpectedly.
Example fix
// before
await client.watch('balance');
const bal = await client.get('balance');
await client.multi().set('balance', Number(bal) - 10).exec(); // throws WatchError on contention
// after
for (let i = 0; i < 10; i++) {
await client.watch('balance');
const bal = await client.get('balance');
try {
await client.multi().set('balance', Number(bal) - 10).exec();
break;
} catch (e) {
if (!(e instanceof WatchError)) throw e;
}
} Defensive patterns
Strategy: retry
Validate before calling
null
Type guard
function isWatchKeyChanged(e: unknown): boolean {
return e instanceof WatchError && e.message === 'One (or more) of the watched keys has been changed';
} Try / catch
try {
await client.multi().set(key, val).exec();
} catch (e) {
if (e instanceof WatchError) {
// watched key changed; re-read and retry the read-modify-write
} else throw e;
} Prevention
- Treat WatchError as an expected control-flow signal, not a fatal error.
- Cap retries and back off to avoid live-lock under contention.
- Use Lua EVAL for high-contention atomic operations instead of WATCH/MULTI.
When it happens
Trigger: await client.watch('k'); ...another client or connection modifies 'k'...; await client.multi().set('k', newVal).exec() — EXEC yields null so the library throws WatchError().
Common situations: Concurrent writers to the same key; implementing atomic compare-and-set via WATCH/MULTI; high-contention counters or inventory updates; background jobs mutating keys a foreground transaction watches.
Related errors
- Client reconnected after WATCH
- The client is closed
- commands failed, see .replies and .errorIndexes for more…
- HIMPORT PREPARE/DISCARD/DISCARDALL are not supported inside…
AI-assisted analysis of redis/node-redis@90fd0652bc (2026-08-11).
Data as JSON: /api/errors/5411ba00c490aebd.
Report an issue: GitHub.
Appendix: source
Thrown at packages/client/lib/client/index.ts:1968
this._self.#queue.addCommand(args, {
chainId,
typeMapping,
slotNumber
})
);
}
promises.push(
this._self.#queue.addCommand(['EXEC'], { chainId, slotNumber })
);
this._self.#scheduleWrite();
const results = await Promise.all(promises),
execResult = results[results.length - 1];
if (execResult === null) {
throw new WatchError();
}
if (selectedDB !== undefined) {
this._self.#selectedDB = selectedDB;
}
return execResult as Array<unknown>;
},
() => ({
batchMode: 'MULTI' as const,
batchSize,
database: this._self.#selectedDB,
clientId: this._self._clientId,
...this._self.#socketTraceContext()
})
);
}
View on GitHub (pinned to 90fd0652bc)