skip to content

Redis has a WATCH command you can issue before starting a queued command batch. What does it do, what does EXEC return when a watched key was changed, and how do you write the retry loop around it correctly?

level: middleimportance: must knowfreq 55%

answer

  1. optimistic, not a lock — writers never blocked
  2. EXEC returns nil array on conflict
  3. EXEC/DISCARD clear watches → re-WATCH each retry
  4. WATCH before the read, never inside MULTI
  5. bounded retries + jittered backoff

basics

~20 s

WATCH marks keys for optimistic locking on your connection. If any client writes a watched key before you call EXEC, EXEC executes nothing and returns a null reply instead of the results array. The client detects the null, re-reads, and retries the whole read-decide-queue-EXEC cycle.

solid answer

~50 s

`WATCH k1 k2` tells Redis to cancel your next transaction if any of those keys is modified before `EXEC`. Nothing is locked — no other client is blocked or slowed. You then read the values you need, compute the new ones in the client, send `MULTI`, queue the writes, and `EXEC`. If a watched key changed in the window, EXEC returns a **null reply** (a nil array, distinct from an empty array) and **not one queued command runs**; otherwise you get the normal array of replies. That makes it a check-and-set primitive: read version, act, commit only if nothing moved. The loop is: WATCH → read → if the read makes the operation unnecessary, `UNWATCH` and bail → MULTI/queue/EXEC → if the reply is null, start over. Two rules people get wrong: `EXEC` and `DISCARD` clear all watches, so every retry must re-issue `WATCH`; and the loop needs a bounded retry count with backoff, otherwise a hot key turns it into a spin.

code

text · 8 lines
text
WATCH mycounter
GET mycounter          # -> "96"
# decide in the client: 96 + 7 = 103 -> clamp to 100
MULTI
SET mycounter 100
EXEC
# (nil)  -> someone wrote mycounter; re-WATCH and start over
# 1) OK  -> applied

go deeper

for a junior

Say what it is: optimistic check-and-set — WATCH the key, EXEC returns nil if someone changed it, so you retry.

for a middle

Walk the full loop in order, explain that EXEC clears watches so each retry must re-WATCH, and note that reads must precede MULTI.

for a senior

Add the cost model (round trips per attempt), bounded retries with jitter, UNWATCH on early exit and pooled-connection hygiene, and when a single atomic command removes the loop entirely.

for a principal

Position optimistic CAS against server-side scripting and against redesigning the data so the contention disappears, and reason about behaviour under conflict rates rather than in the happy path.

## The problem WATCH solves Redis commands are individually atomic, but a *read-modify-write* spanning a round trip is not: you `GET` a value, compute something from it in your application, and `SET` the result — and between the GET and the SET another client may have changed it, so your write silently destroys their update. This is the lost-update problem, and Redis's answer is optimistic rather than pessimistic: instead of holding a lock, you declare what you read and let the server cancel your write if that reading turned stale. ## Mechanics `WATCH key [key ...]` registers the keys against your connection. Internally the server keeps a watchers list per key; any command that modifies the key marks every watching client as *dirty*. Nothing about the key is locked, slowed, or made visible to other clients — writers proceed at full speed and never know they invalidated somebody. When you later send `EXEC`, the server checks your dirty flag. If it is set, EXEC discards the queue, clears your watches, and replies with a **null array** — protocol-level nil, which client libraries surface as `nil`, `None`, `null` or an empty `Optional` depending on the language. This is deliberately different from an error: nothing went wrong, the transaction simply did not apply. If the flag is clear, EXEC executes the block as usual. A few rules follow: - `EXEC` clears all watched keys, whether it applied or not. So does `DISCARD`, and so does closing the connection. **Every retry must WATCH again** — forgetting this is the classic bug, and it produces a loop that appears to work while providing no protection at all. - `UNWATCH` drops watches without running anything. Use it on the early-exit path, when the value you read tells you there is nothing to do, so you do not leave the connection carrying watches into the next operation on a pooled connection. - `WATCH` cannot be called inside `MULTI` — it is an error. Watch first, then open the block. ## The loop, written correctly 1. `WATCH key` 2. Read the current value(s) with ordinary commands — these reads happen outside the block, so you see real values, not `QUEUED` placeholders. 3. Decide in the client. If no write is needed, `UNWATCH` and return. 4. `MULTI`, queue the writes, `EXEC`. 5. Null reply → conflict. Increment an attempt counter, sleep a small randomised backoff, go to 1. Non-null → done. Two details separate a working loop from a fragile one. First, **bound the attempts**: an unbounded loop on a contended key spins forever and burns CPU and connections on both sides; after N attempts, fail the operation and let the caller decide. Second, **jitter the backoff**: without it, all conflicting clients retry in lockstep and keep colliding. Also remember you must not read *inside* the transaction. Commands between MULTI and EXEC return `QUEUED`, and their real replies arrive only in the EXEC array — far too late to influence what you queue. All decisions are made before MULTI, which is precisely why you need WATCH to validate them. ## Why the reads have to be inside the watch window The watch must be established *before* the read whose freshness you depend on. If you read first and watch afterwards, a write landing in between is invisible to the server's dirty tracking, and EXEC happily applies a decision based on a stale value. Order is WATCH, then read. ## Cost model One successful pass costs roughly three round trips (WATCH, the read(s), MULTI…EXEC — pipelining can fold some of them). Every conflict pays that again. So WATCH is excellent when conflicts are rare and the logic must live in the client, and poor when conflicts are common — at which point a server-side script that does the read and the write in one atomic pass is the better instrument. ## When you do not need WATCH at all A large share of WATCH loops in real code are re-implementing a command that already exists atomically: `INCRBY` for counters, `SET key val NX` for claim-once, `HSETNX`, `ZADD … GT` for monotonic scores, `SET key val XX`, `GETSET`/`SET … GET` for take-and-replace. Reach for a single atomic command first; use WATCH only when the decision is genuinely application logic.

  • Your retry loop calls WATCH once outside the loop and retries only the MULTI/EXEC part. What goes wrong?
    EXEC clears all watched keys whether or not it applied, so from the second attempt onward you are running an unguarded transaction. It will succeed every time and silently overwrite concurrent updates — the loop looks healthy while providing no protection. WATCH must be re-issued at the top of every attempt, along with a fresh read.
  • Can you read a key inside MULTI and use the value to decide what else to queue?
    No. Commands sent after MULTI reply only `QUEUED`; their real results appear in the EXEC array after everything has already run. All branching must happen before MULTI, on values read in the watch window — which is exactly why WATCH exists, to invalidate the transaction if those values changed.
  • How is a nil EXEC reply different from an EXECABORT error?
    Nil means the transaction was cancelled cleanly because a watched key changed — nothing ran and nothing is wrong; retry. EXECABORT means a command failed to queue (unknown command, wrong arity, out-of-memory rejection), so the transaction was discarded before execution; retrying the identical batch will abort again until the cause is fixed.

Like editing a wiki page: you do not lock it, you submit your edit with the revision you started from, and the server rejects the save if someone else has since changed the page.

saying these in an interview costs you the question

  • Describing WATCH as locking the key or blocking other writers
  • Not re-issuing WATCH after a failed EXEC
  • Reading the key before calling WATCH
  • Trying to read a value inside MULTI to decide what to queue next
  • Writing an unbounded retry loop with no backoff or jitter
  • Confusing the nil EXEC reply with an error or with an empty result set

context