Module F-11·20 min read

What Redis transactions actually guarantee, why runtime errors do not roll back, WATCH-based optimistic locking for read-modify-write patterns, retry loops, and when WATCH contention demands Lua scripts instead.

F-10 — Transactions: MULTI, EXEC, and Optimistic Locking with WATCH

Who this module is for: You need to execute multiple Redis commands as a unit — without another client's commands interleaving — but you are not sure how Redis transactions work, what "atomic" means in this context, or how to handle the read-modify-write pattern safely. This module covers MULTI/EXEC transactions, their limitations, and WATCH-based optimistic locking for conditional execution.


The Problem: Interleaved Commands

Suppose you want to transfer credits between two user accounts:

Read user:1001 balance → 500
Read user:1002 balance → 200
Write user:1001 balance = 400  (deducted 100)
Write user:1002 balance = 300  (added 100)

Between your read and write, another client could modify the same keys. The result: a race condition where credits appear or disappear. In PostgreSQL, you would wrap this in a transaction. In Redis, you use MULTI/EXEC.


MULTI/EXEC Transactions

MULTI   → start a transaction block
[commands queued here are not executed immediately]
EXEC    → execute all queued commands atomically
DISCARD → discard the transaction queue (rollback before EXEC)
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECRBY balance:user:1001 100
QUEUED
127.0.0.1:6379> INCRBY balance:user:1002 100
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 400    ← result of DECRBY
2) (integer) 300    ← result of INCRBY

Between MULTI and EXEC, every command returns QUEUED — it is added to the transaction queue but not executed. EXEC sends all queued commands to Redis, which executes them sequentially without any other client's commands interleaving.

What "atomic" means here: The commands execute in order, without interruption from other clients. It is not atomic in the database sense — there is no rollback on error.

DISCARD

MULTI
SET key1 "value"
SET key2 "value"
DISCARD   → clears the queue; no commands are executed

Transaction Errors: Two Types

Syntax errors (caught at queue time): If a command is syntactically wrong, the error is returned at queue time. When you call EXEC, the entire transaction is discarded:

MULTI
SET key1 "value"
INVALIDCMD arg   ← syntax error
EXEC
→ (error) EXECABORT Transaction discarded because of previous errors.

Runtime errors (caught at execution time): If a command is syntactically valid but fails at runtime (e.g., type mismatch), the error is returned for that specific command, but the rest of the transaction continues:

MULTI
SET key1 "hello"
LPUSH key1 "will-fail"   ← key1 is a String, not a List — runtime error
SET key3 "value"
EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) OK

key3 was set successfully despite key2's error. Redis transactions do not roll back on runtime errors. This surprises most engineers coming from SQL databases. The reasoning: most runtime errors in Redis are programming bugs (wrong type), not transient failures. There is no mechanism to "undo" an already-executed SET.


The Critical Limitation: No Conditional Logic Inside Transactions

You cannot read a value inside MULTI/EXEC and use it to make a decision:

MULTI
GET balance:user:1001   ← returns QUEUED, not the actual value
IF balance > 100 THEN   ← impossible — you do not have the value yet
  DECRBY balance:user:1001 100
EXEC

All reads inside a transaction return QUEUED — the actual values are only available after EXEC returns the full result array. By that time, you have already submitted the writes.

This means the naive MULTI/EXEC approach cannot implement "check balance then debit" — it can only implement "debit unconditionally as an atomic unit."

For conditional operations, you need WATCH.


WATCH: Optimistic Locking

WATCH implements optimistic concurrency control. It monitors one or more keys and makes the subsequent transaction conditional: if any watched key is modified by any client between WATCH and EXEC, the transaction is aborted (returns nil instead of executing).

WATCH key [key ...]   → monitor these keys; abort EXEC if any are modified before then
UNWATCH              → stop watching all keys (also called automatically by EXEC/DISCARD)

The WATCH + MULTI + EXEC Pattern

# Read the current balance
WATCH balance:user:1001
GET balance:user:1001   → 500

# Start the transaction
MULTI
DECRBY balance:user:1001 100   ← queued

# If nobody modified balance:user:1001 since WATCH:
EXEC   → returns [400]

# If someone else modified balance:user:1001 since WATCH:
EXEC   → returns nil (transaction aborted)

When EXEC returns nil, it means the watched key changed — your reads are stale. The correct response is to retry the entire operation from the beginning: re-read, re-apply logic, re-attempt the transaction.

Safe Balance Deduction in Node.js

typescript
import Redis from 'ioredis'; const redis = new Redis(); async function deductBalance(userId: string, amount: number): Promise<boolean> { const key = `balance:${userId}`; // Retry loop for optimistic locking for (let attempt = 0; attempt < 5; attempt++) { await redis.watch(key); const balance = parseInt(await redis.get(key) ?? '0', 10); if (balance < amount) { await redis.unwatch(); return false; // Insufficient balance — no retry needed } const result = await redis .multi() .decrby(key, amount) .exec(); if (result !== null) { // Transaction succeeded — result is the array of command results return true; } // result === null → watched key was modified; retry // Small jitter to reduce contention on hot keys await sleep(Math.random() * 10); } throw new Error('Could not complete transaction after 5 attempts'); } function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); }

The retry loop is essential. Without it, a contested key would simply fail after the first concurrent modification.

When WATCH Retries Become a Problem

On a hot key (many concurrent writers), every client watches and retries. Under high contention, most attempts fail and retry repeatedly — this is the same thundering herd problem as cache stampedes. Symptoms:

  • CPU spikes on the application side (lots of retries)
  • Redis shows many EXEC calls but few actual writes
  • Latency increases proportionally to the number of concurrent writers

For hot counters or balances with many concurrent writers, Lua scripts are the correct solution (covered in A-2). A Lua script executes atomically in a single pass without the watch-retry cycle.


Transactions in ioredis

ioredis chains transaction commands via .multi():

typescript
// Build and execute a transaction const results = await redis .multi() .set('key1', 'value1') .incr('counter') .hset('user:1001', 'field', 'value') .exec(); // results: [[null, 'OK'], [null, 1], [null, 1]] // Each: [error, value]

With WATCH:

typescript
await redis.watch('inventory:item:999'); const quantity = parseInt(await redis.get('inventory:item:999') ?? '0', 10); if (quantity === 0) { await redis.unwatch(); throw new Error('Out of stock'); } const results = await redis .multi() .decrby('inventory:item:999', 1) .rpush('orders:pending', JSON.stringify({ item: 999, qty: 1 })) .exec(); if (results === null) { // Retry — inventory changed concurrently }

What Transactions Do Not Solve

No Read-Your-Own-Writes Inside a Transaction

Commands inside MULTI/EXEC execute sequentially, but you cannot use the result of one command to feed into the next (because all results come back together after EXEC):

MULTI
INCR counter        ← returns QUEUED
GET counter         ← cannot use INCR's result as GET's key
EXEC
→ [1, "1"]          ← you get both results, but could not use them conditionally

For read-then-write logic, use Lua scripts.

No Cross-Key Atomicity in Redis Cluster

In Redis Cluster, keys are distributed across nodes based on their hash slot. A MULTI/EXEC transaction can only be atomic across keys on the same node. If key1 and key2 are on different nodes, a transaction spanning both will fail.

Solution: use hash tags to co-locate related keys on the same slot:

MULTI
DECRBY {user:1001}:balance 100    ← {user:1001} forces same hash slot
INCRBY {user:1002}:balance 100    ← {user:1001} and {user:1002} → different slots!
EXEC   ← error: CROSSSLOT

For cross-key atomicity in Cluster, use Lua scripts (A-2) with KEYS array containing all keys (Cluster routes based on the first KEYS argument).


Transaction Use Cases

Good fits for MULTI/EXEC:

  • Atomic writes across multiple related keys (no reads needed mid-transaction)
  • Counter reset + audit log: GETDEL counter + LPUSH audit_log
  • Multi-key set with expiry: set several keys and expire them together
  • Batch updates where partial application would be incorrect

Poor fits for MULTI/EXEC (use Lua instead):

  • Read-modify-write patterns with conditional logic
  • High-contention keys with many concurrent writers
  • Complex multi-step operations that depend on intermediate results

Summary

  • MULTI begins a transaction queue; EXEC executes all queued commands atomically; DISCARD clears the queue
  • "Atomic" means no other client's commands interleave — not that the transaction rolls back on error
  • Syntax errors at queue time abort the whole transaction; runtime errors in EXEC skip that command and continue
  • You cannot use a read result inside a transaction to make a conditional decision
  • WATCH key enables optimistic locking: if the watched key changes before EXEC, the transaction returns nil
  • Always retry on nil from EXEC — read the current state again and re-attempt
  • High-contention WATCH scenarios → use Lua scripts for lock-free atomic operations
  • In Redis Cluster, transactions only work across keys on the same hash slot — use hash tags to co-locate keys

Next: F-11 — Caching Patterns: Cache-Aside, Write-Through, Write-Behind, and Read-Through — the four caching strategies, when to use each, and the consistency trade-offs that no tutorial mentions.

© 2026 Jatin Jain Saraf (JJS). All rights reserved.