redis/node-redis · error · WatchError
Client reconnected after WATCH
Error message
Client reconnected after WATCH
What it means
Thrown in _executeMulti when the socketEpoch recorded at WATCH time no longer matches the current socketEpoch. socketEpoch increments on every successful (re)connection, so a reconnect between WATCH and EXEC invalidates the optimistic lock because the new connection has no WATCH registered on the server. The library refuses to send a transaction whose safety it cannot guarantee.
Solutions
- Wrap the WATCH + MULTI/EXEC sequence in a retry loop that re-issues WATCH on the new connection after a reconnect.
- Keep the WATCH-to-EXEC window as short as possible; queue all commands and call exec() immediately.
- Catch WatchError and distinguish message === 'Client reconnected after WATCH' from a real key-change violation, retrying the former unconditionally.
- Move atomic compare-and-set logic into a server-side Lua script to avoid the WATCH reconnect fragility entirely.
Example fix
// before
await client.watch('key');
// ... network blip causes reconnect ...
await client.multi().set('k','v').exec(); // WatchError('Client reconnected after WATCH')
// after
for (let attempt = 0; attempt < 5; attempt++) {
await client.watch('key');
try {
return await client.multi().set('k','v').exec();
} catch (e) {
if (e instanceof WatchError) continue; // reconnect or key-change: retry
throw e;
}
} Defensive patterns
Strategy: retry
Validate before calling
null
Type guard
function isReconnectWatchError(e: unknown): boolean {
return e instanceof WatchError && e.message === 'Client reconnected after WATCH';
} Try / catch
for (let i = 0; i < MAX; i++) {
await client.watch(key);
try {
return await client.multi().set(key, val).exec();
} catch (e) {
if (e instanceof WatchError) continue; // reconnect or key change
throw e;
}
} Prevention
- Keep the WATCH-to-EXEC window minimal; queue commands and exec() immediately.
- Always wrap WATCH/MULTI/EXEC in a retry loop; reconnects are always possible.
- Prefer Lua scripts for atomic CAS to avoid WATCH reconnect fragility.
When it happens
Trigger: Calling await client.watch('key'), then the connection drops and auto-reconnects (socketEpoch++), then calling await multi.exec(). Any network interruption, Redis restart, or failover that triggers a reconnect inside the WATCH..EXEC window.
Common situations: Long-running transactions held open while the network is unstable; Redis Sentinel/Cluster failover between WATCH and EXEC; socketTimeout triggering a reconnect mid-transaction; environments with intermittent TCP resets.
Related errors
- One (or more) of the watched keys has been changed
- The client is closed
- commands failed, see .replies and .errorIndexes for more…
- HIMPORT PREPARE/DISCARD/DISCARDALL are not supported inside…
- Reconnect strategy should return `false | Error | number`…
AI-assisted analysis of redis/node-redis@90fd0652bc (2026-08-11).
Data as JSON: /api/errors/8f9a3b4fa7c37782.
Report an issue: GitHub.
Appendix: source
Thrown at packages/client/lib/client/index.ts:1936
chainId = Symbol('MULTI Chain')
) {
assertNoHimportSessionCommands(commands);
const dirtyWatch = this._self.#dirtyWatch;
this._self.#dirtyWatch = undefined;
const watchEpoch = this._self.#watchEpoch;
this._self.#watchEpoch = undefined;
if (!this._self.#socket.isOpen) {
throw new ClientClosedError();
}
if (dirtyWatch) {
throw new WatchError(dirtyWatch);
}
if (watchEpoch && watchEpoch !== this._self.socketEpoch) {
throw new WatchError('Client reconnected after WATCH');
}
const batchSize = commands.length;
return trace(CHANNELS.TRACE_BATCH,
async () => {
const typeMapping = this._commandOptions?.typeMapping;
const promises: Array<Promise<unknown>> = [
this._self.#queue.addCommand(['MULTI'], { chainId, slotNumber }),
];
for (const { args } of commands) {
promises.push(
this._self.#queue.addCommand(args, {
chainId,
typeMapping,
slotNumber
})View on GitHub (pinned to 90fd0652bc)