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

  1. Wrap the WATCH + MULTI/EXEC sequence in a retry loop that re-issues WATCH on the new connection after a reconnect.
  2. Keep the WATCH-to-EXEC window as short as possible; queue all commands and call exec() immediately.
  3. Catch WatchError and distinguish message === 'Client reconnected after WATCH' from a real key-change violation, retrying the former unconditionally.
  4. 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

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


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)