WATCH and Optimistic Locking

Redis transactions built with MULTI and EXEC guarantee that no other client’s commands interleave once your batch starts running — but on their own they don’t stop someone else from changing the data you’re about to act on before your transaction begins. WATCH closes that gap. It gives Redis a way to do optimistic locking: instead of blocking every other client while you read a value and decide what to write next, Redis lets everyone keep working, and only refuses to commit your transaction if a key you depend on actually changed underneath you. This lesson covers how WATCH works internally, how to build a safe check-and-set pattern with it, and the mistakes that trip people up.

Overview: How Optimistic Locking Works in Redis

There are two broad ways to protect against two clients racing to update the same piece of data. Pessimistic locking stops the race before it starts: a client acquires an exclusive lock, does its read-modify-write, then releases the lock, and every other client is forced to wait. Optimistic locking takes the opposite bet: it assumes conflicts are rare, lets every client read and compute freely, and only checks for a conflict at the very last moment, right before committing. If a conflict is found, the commit is refused and the client tries again. Redis’s WATCH command implements the optimistic approach, and it’s the standard way to build a safe “check value, then update it” (compare-and-swap) operation out of commands that aren’t atomic on their own.

Here’s what actually happens on the server. When you run WATCH key, Redis adds that key to a list of “watched keys” tied to your specific client connection — no lock is taken, and no other client is blocked in any way. Internally, every key in Redis carries a notion of having been “touched”: any command that writes to a key (a SET, a DEL, an LPUSH, even the key quietly expiring due to a TTL) marks it as touched, and Redis checks whether any client is watching that key. When you later call EXEC, before running a single queued command Redis checks: has any key on my watch list been touched since I called WATCH? If yes, the entire transaction is aborted — none of the queued commands run — and EXEC returns a null reply. If no watched key was touched, Redis proceeds to run every queued command back-to-back with no other client’s commands able to interleave, because Redis is single-threaded and EXEC itself is one atomic step from the server’s point of view. This is the whole trick: the “locking” isn’t a lock at all, it’s a conflict check performed atomically at commit time.

Because nothing is actually locked, other clients are never blocked or slowed down by your WATCH — they can read and write the same keys freely. The cost of optimism is on your side: if someone does modify a watched key first, your transaction is thrown away and you must decide what to do next, which in practice almost always means re-reading the current value and retrying the whole sequence. This makes WATCH ideal for keys with light-to-moderate contention (a specific user’s balance, a specific product’s stock count) and a poor fit for keys many clients hit constantly (a single global counter under heavy concurrent load), where retries would pile up. For that kind of hot key, a Lua script executed with EVAL — covered elsewhere in this section — is usually a better tool, since the whole read-modify-write happens as one atomic step server-side with no possibility of conflict and therefore no retry loop at all.

Syntax

The optimistic-locking pattern combines a few commands used together. None of them take complex options — the pattern is about the order you call them in.

Command What it does
WATCH key [key ...] Marks one or more keys to monitor for changes made by any client from this point forward.
MULTI Starts queuing the commands that follow into a transaction, instead of running them immediately.
EXEC Runs the queued commands, but only if no watched key was modified since WATCH; otherwise aborts and returns a null reply.
DISCARD Cancels a transaction that was started with MULTI without running the queued commands, and clears the watch list.
UNWATCH Clears the current connection’s watch list without starting or affecting a transaction.

The general shape of the pattern is: watch the keys you’re about to base a decision on, read their current values, decide what to write, then queue and commit that write inside a transaction. WATCH must always be called before MULTI — calling it once you’re inside a transaction block is an error, covered in Common Mistakes below.

WATCH key [key ...]
MULTI
command1
command2
EXEC

Examples

Example 1: A transaction that commits normally

Here, nothing else touches inventory:sku1001 between WATCH and EXEC, so the queued command runs and the stock count is decremented.

SET inventory:sku1001 50
WATCH inventory:sku1001
GET inventory:sku1001
MULTI
DECRBY inventory:sku1001 5
EXEC

Output:

OK
OK
"50"
OK
QUEUED
1) (integer) 45

Each command up through MULTI runs and replies immediately as usual. Once MULTI is active, DECRBY isn’t executed — Redis just queues it and replies QUEUED. EXEC then checks the watch list, finds no conflict, runs the single queued command, and returns its result wrapped in an array — here, one element, the new stock count of 45.

Example 2: A transaction that aborts because a watched key changed

This time, something modifies the watched key after WATCH but before EXEC — in a real application this write would come from a second client racing to update the same account, but the effect is identical no matter who makes the write, so it’s shown here on the same connection to keep the example self-contained.

SET account:bob:balance 100
WATCH account:bob:balance
SET account:bob:balance 90
MULTI
SET account:bob:balance 200
EXEC

Output:

OK
OK
OK
OK
QUEUED
(nil)

The second SET touches account:bob:balance after it was already being watched, so Redis flags the connection’s watch as broken. MULTI and the queued SET still reply normally — Redis doesn’t know yet whether the transaction will run — but when EXEC is called it sees the watched key was touched and refuses to run anything, replying with a null array. The balance is left at 90, not 200: the transaction had no effect at all.

Example 3: A realistic check-then-update — reserving the last ticket

This is the pattern’s real purpose: read a value, decide based on it, and commit the decision only if nothing changed the value while you were deciding.

SET tickets:concert42:available 1
WATCH tickets:concert42:available
GET tickets:concert42:available
MULTI
DECR tickets:concert42:available
EXEC

Output:

OK
OK
"1"
OK
QUEUED
1) (integer) 0

GET shows one ticket is available, so the application decides to proceed with the reservation and queues a DECR. Because no other client touched the key in between, EXEC commits it and the counter drops to 0. If a second customer’s request had run its own WATCH/GET/MULTI/EXEC sequence concurrently and happened to modify the key first, this customer’s EXEC would instead return (nil) exactly like Example 2 — the application would then re-read the key, see 0 available, and tell the customer there are no tickets left instead of overselling.

If the check fails before you ever reach MULTI — say GET shows 0 tickets available — there’s no point starting a transaction at all. Call UNWATCH to release the watch cleanly:

SET tickets:concert43:available 0
WATCH tickets:concert43:available
GET tickets:concert43:available
UNWATCH

Output:

OK
OK
"0"
OK

How It Works Step by Step

Walking through Example 3’s successful path in terms of what the server actually does:

  • 1. WATCH tickets:concert42:available — Redis records, against your connection, that you care about this key’s current version. No other client is affected in any way.
  • 2. GET tickets:concert42:available — a normal read, executed and replied to immediately, outside of any transaction.
  • 3. MULTI — Redis switches your connection into queuing mode. Every command you send now gets validated for basic syntax and queued, not executed, and replies QUEUED.
  • 4. DECR tickets:concert42:available — queued, not yet run.
  • 5. EXEC — Redis first checks every key on your watch list against its internal “was this touched since being watched” flag. Because Redis is single-threaded, this check and the subsequent execution of the queued commands happen as one uninterruptible step — nothing can sneak a write in between the check and the run. Finding no conflict, it executes DECR and returns its reply inside an array.
  • 6. Cleanup — whether EXEC commits or aborts, your connection’s watch list is automatically cleared afterward, so you don’t need to call UNWATCH yourself in the normal case.

The abort path (Example 2) is identical through step 4, except that at step 5 Redis finds the touched flag set on account:bob:balance and skips running any queued command, replying to EXEC with a null array instead.

Common Mistakes

Mistake 1: Treating WATCH as if it were a lock

WATCH does not block any other client from reading or writing the watched key — it only arranges for your own EXEC to fail if someone else writes to it first. Code that calls WATCH and then assumes the value can’t possibly change before its own EXEC is simply wrong; Example 2 above shows exactly this happening. The fix isn’t a different command — it’s application logic: always check whether EXEC returned (nil), and if it did, re-run the whole read-decide-write sequence from the top rather than assuming success.

Mistake 2: Calling WATCH after MULTI has already started

WATCH only makes sense as a setup step before a transaction begins queuing commands, so Redis rejects it once you’re inside a MULTI block:

MULTI
WATCH mykey

Output:

OK
(error) ERR WATCH inside MULTI is not allowed

Always call every WATCH you need first, and only call MULTI once you’re done watching and ready to start queuing the write commands.

Mistake 3: Skipping WATCH entirely for a read-then-write update

A plain GET followed later by a plain SET, with no WATCH in between, looks fine in isolation and will run without any error — but it’s not safe under concurrency, because nothing stops a second client’s SET from landing between your GET and your SET, silently overwriting their update with a value your client computed from stale data:

SET counter:pageviews 10
GET counter:pageviews
SET counter:pageviews 11

Output:

OK
"10"
OK

This command sequence never errors, which is exactly why the bug is dangerous — it will pass casual testing and only misbehave under real concurrent traffic. Wrap the read and write in WATCH/MULTI/EXEC so a competing write is detected and the stale update is rejected instead of silently applied:

SET counter:pageviews 10
WATCH counter:pageviews
GET counter:pageviews
MULTI
INCR counter:pageviews
EXEC

Output:

OK
OK
"10"
OK
QUEUED
1) (integer) 11

(For a plain increment like this one, INCR is atomic on its own and doesn’t need WATCH at all — it’s shown here purely to illustrate the pattern. Reach for WATCH when the new value depends on more logic than a single atomic command can express.)

Best Practices

  • Only WATCH the keys your decision actually depends on — watching extra keys only increases how often unrelated writes cause avoidable retries.
  • Keep the time between WATCH and EXEC as short as possible; don’t do slow work like network calls to another service in between, since every extra millisecond widens the window for a real conflict.
  • Always wrap the watch-read-decide-write sequence in an application-level retry loop. A (nil) from EXEC means “try again,” not “something went wrong.”
  • For a single atomic operation (increment, append, set-if-not-exists), reach for the dedicated atomic command (INCR, APPEND, SETNX) instead of WATCH — it’s simpler and never needs a retry.
  • For complex read-modify-write logic on a hot key with heavy contention, consider a Lua script via EVAL instead — it runs as one atomic server-side step with no watch and no retry loop needed.
  • Remember that EXEC and DISCARD both automatically clear the watch list — only call UNWATCH yourself when you decide not to run a transaction after already calling WATCH.

Practice Exercises

  • Seat reservation: Create a key seat:A1:status set to "available". Using WATCH, GET, and MULTI/EXEC, write the command sequence that reserves the seat only if it’s still "available", setting it to "reserved". Then work out what should happen in your application code if EXEC comes back (nil).
  • Rate limiter with a cap: Create a key request:user42:count starting at 0, representing requests made in the current window, with a limit of 5. Using WATCH and GET to check the current count before deciding whether to queue an INCR, write the sequence for one allowed request and explain what your code should do differently once the count reaches 5.
  • Two-key transfer: Create wallet:alice:balance at 500 and wallet:bob:balance at 100. Write a WATCH/MULTI/EXEC sequence that watches both keys and, inside one transaction, debits 50 from Alice and credits 50 to Bob. Explain why watching only Alice’s key would be a mistake here.

Summary

  • WATCH implements optimistic locking: it never blocks other clients, it only marks keys so Redis can detect a conflicting write before your transaction commits.
  • If any watched key is modified — or deleted, or expires — between WATCH and EXEC, by any client including your own connection, EXEC aborts and returns (nil) without running any queued command.
  • A (nil) from EXEC means “retry the whole read-decide-write sequence,” not “an error occurred” — build a retry loop around the pattern in application code.
  • EXEC and DISCARD both automatically clear the watch list; use UNWATCH only to cancel a pending watch without running a transaction.
  • WATCH must be called before MULTI — calling it inside an active transaction returns an error.
  • For a hot key under heavy contention, a Lua script via EVAL is often a better fit than a WATCH-based retry loop, since it commits as one atomic step with no possibility of conflict.