Skip to content

Transactions

Verified on Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. The # cli/... transcript is one session in document order, replayed by the course harness; the “attempts” total in the script output depends on timing and will differ on your machine.

A transaction is a sequence of commands that are queued and then executed as one uninterrupted block: no other client’s command runs between them once EXEC fires. You open one with MULTI, issue commands (each replies QUEUED), and run them with EXEC or throw them away with DISCARD. The full behaviour is in the Transactions documentation.

cli/tsl-transactions-1.redis
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET balance 100
QUEUED
127.0.0.1:6379> DECRBY balance 30
QUEUED
127.0.0.1:6379> GET balance
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (integer) 70
3) "70"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET temp "draft"
QUEUED
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> EXISTS temp
(integer) 0
127.0.0.1:6379> SET mystr hello
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k1 a
QUEUED
127.0.0.1:6379> INCR mystr
QUEUED
127.0.0.1:6379> SET k2 b
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (error) ERR value is not an integer or out of range
3) OK
127.0.0.1:6379> GET k1
"a"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k3 c
QUEUED
127.0.0.1:6379> SET k4
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379> SET k5 e
QUEUED
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> GET k3
(nil)
127.0.0.1:6379> EXEC
(error) ERR EXEC without MULTI
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> WATCH stock
(error) ERR WATCH inside MULTI is not allowed
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> SET stock 5
OK
127.0.0.1:6379> WATCH stock
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECRBY stock 1
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 4
127.0.0.1:6379> UNWATCH
OK

What the session shows:

  • Inside MULTI every command replies QUEUED; the real results arrive as one list from EXEC.
  • A runtime error does not roll back. INCR mystr failed (the value is not a number) but k1 and k2 were still written: Redis has no rollback, by design. Check every element of the EXEC reply.
  • A queue-time error aborts everything. A SET with a missing argument was rejected immediately, and EXEC answered EXECABORT: nothing ran (GET k3 is (nil), although SET k3 c had been queued). This is the one case that behaves like an all-or-nothing abort.
  • EXEC without MULTI is an error, and WATCH cannot be issued inside MULTI; it must come before it.
MULTI
SET balance 100
DECRBY balance 30
GET balance
EXEC
MULTI
SET temp "draft"
DISCARD
EXISTS temp

WATCH key marks keys to monitor. If any watched key is modified by anyone (another client, or your own connection outside the transaction) before EXEC, the whole transaction is skipped and EXEC returns (nil). That is a check-and-set (CAS): read, compute, write only if nothing changed, otherwise retry. UNWATCH (and EXEC, and DISCARD) clears the watches. The last lines of the transcript are the uncontended path; the contended path needs two connections:

src/transactions-watch.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const redis = new Redis(url);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// 1) WATCH aborts the transaction when another connection touches the key
const other = new Redis(url);
await redis.set('b3:stock', '5');
await redis.watch('b3:stock');
const seen = Number(await redis.get('b3:stock'));
await other.decr('b3:stock'); // a second client sells one item in between
const aborted = await redis.multi().set('b3:stock', String(seen - 1)).exec();
console.log('EXEC after another client changed the key ->', aborted, '| stock =', await redis.get('b3:stock'));
// 2) errors: queue-time error aborts everything, runtime error does not roll back
await redis.set('b3:str', 'hello');
const runtime = await redis.multi().set('b3:k1', 'a').incr('b3:str').set('b3:k2', 'b').exec();
console.log('runtime error, per-command tuples:', JSON.stringify(runtime!.map(([e, r]) => [e ? e.message : null, r])));
console.log(' k1 and k2 were written anyway:', await redis.get('b3:k1'), await redis.get('b3:k2'));
try {
await redis.multi().set('b3:k3', 'c').call('SET', 'b3:k3').exec(); // SET with a missing argument
} catch (e) {
console.log('queue-time error -> exec() rejects:', (e as Error).message, '| k3 =', await redis.get('b3:k3'));
}
// 3) optimistic locking done right: one connection PER attempt, retry on null
async function buy(): Promise<{ ok: boolean; attempts: number }> {
const conn = redis.duplicate(); // WATCH state lives on the connection, so it must not be shared
try {
for (let attempt = 1; attempt <= 50; attempt++) {
await conn.watch('b3:cas');
const stock = Number(await conn.get('b3:cas'));
if (stock <= 0) { await conn.unwatch(); return { ok: false, attempts: attempt }; }
const res = await conn.multi().set('b3:cas', String(stock - 1)).exec();
if (res) return { ok: true, attempts: attempt }; // null = someone else changed it, try again
await sleep(Math.random() * 5);
}
return { ok: false, attempts: 50 };
} finally {
conn.disconnect();
}
}
await redis.set('b3:cas', '10');
const buyers = await Promise.all(Array.from({ length: 20 }, buy));
console.log('WATCH + retry, 20 buyers for 10 items: sold', buyers.filter((b) => b.ok).length, '| stock left', await redis.get('b3:cas'), '| total attempts', buyers.reduce((n, b) => n + b.attempts, 0));
// 4) the same code on ONE shared connection oversells
async function buyShared(): Promise<boolean> {
await redis.watch('b3:shared');
const stock = Number(await redis.get('b3:shared'));
if (stock <= 0) return false;
const res = await redis.multi().set('b3:shared', String(stock - 1)).exec();
return res !== null;
}
await redis.set('b3:shared', '10');
const sold = (await Promise.all(Array.from({ length: 20 }, buyShared))).filter(Boolean).length;
console.log('shared connection, 20 buyers for 10 items: sold', sold, '| stock left', await redis.get('b3:shared'));
await redis.del('b3:stock', 'b3:str', 'b3:k1', 'b3:k2', 'b3:k3', 'b3:cas', 'b3:shared');
other.disconnect();
await redis.quit();

Real output (this Mac):

EXEC after another client changed the key -> null | stock = 4
runtime error, per-command tuples: [[null,"OK"],["ERR value is not an integer or out of range",null],[null,"OK"]]
k1 and k2 were written anyway: a b
queue-time error -> exec() rejects: EXECABORT Transaction discarded because of previous errors. | k3 = null
WATCH + retry, 20 buyers for 10 items: sold 10 | stock left 0 | total attempts 88
shared connection, 20 buyers for 10 items: sold 20 | stock left 9

What it proves:

  • The abort. The other connection decremented stock (5 to 4) after our WATCH; our EXEC returned null and wrote nothing (the stock stays 4).
  • The two error kinds in ioredis. A runtime error comes back inside the exec() result as an [err, result] tuple and the other commands still ran; a queue-time error makes exec() reject with EXECABORT.
  • A correct CAS loop. 20 buyers competing for 10 items each used their own connection (redis.duplicate()), retried on null, and exactly 10 sold with stock ending at 0. The 88 attempts are the price of contention: losers retry. Under heavy contention on one key this gets expensive; then use a Lua script (Lua lesson) or an atomic command like DECR.
  • A shared connection breaks it. The same code on one shared connection sold 20 of 10 items and left stock at 9. WATCH state belongs to the connection, and EXEC clears all watches of that connection: the first buyer’s EXEC un-watched the key for everyone else who had read the same stock, so their transactions were never aborted and they all wrote stock - 1 from a stale read.

An assistant writes a “safe” stock decrement with optimistic locking:

🐛 Spot the bug

const redis = new Redis(); // one client shared by every request
async function buy(): Promise<boolean> {
await redis.watch('stock');
const stock = Number(await redis.get('stock'));
if (stock <= 0) return false;
const res = await redis.multi().set('stock', String(stock - 1)).exec();
return res !== null; // null means someone else won; caller may retry
}
Show the answer

The logic looks like the textbook CAS loop, but WATCH is tied to the connection, and a Node.js Redis client is one connection shared by every concurrent request. Requests interleave their WATCH/GET/EXEC, the first EXEC clears the watch for all of them, and the rest commit a stale stock - 1. In the run above, 20 buyers on one connection sold 20 of 10 items; with a connection per attempt exactly 10 sold. Take a dedicated connection for each attempt and disconnect it (or return it to a pool) afterwards, and add a retry limit:

const conn = redis.duplicate(); // WATCH state lives on this connection
try { /* watch, get, multi().exec(), retry on null */ } finally { conn.disconnect(); }

MULTI/EXEC is a sealed envelope handed to the clerk: everything inside is processed together, nobody cuts in. It is not an undo button: if one instruction inside is impossible, the clerk does the rest anyway. WATCH is a note pinned to the counter saying “cancel my envelope if anyone touches this key first”, and the note belongs to you at that window, not to the shop.

ChoiceBenefitCost
MULTI/EXECQueued commands run back-to-back with no interleavingNo rollback for runtime errors; no branching on intermediate results
WATCH (optimistic CAS)Detects a race without locking; cheap at low contentionYou write the retry loop and hold a connection per attempt; retries pile up under contention
Lua script insteadReal branching, atomic, no retriesMore code to maintain, blocks the server while running
Plain atomic command (INCR, DECR, SET NX)Simplest and fastestOnly works when one command expresses the change
  • “MULTI/EXEC rolls back like SQL.” Only queue-time errors discard everything; a runtime error leaves the other commands applied.
  • “WATCH is a lock.” It is a check at EXEC time; nothing is blocked, and EXEC returning null means retry.
  • “One shared client is fine for WATCH.” Watches belong to the connection; sharing it oversold 20 of 10.
  • “EXEC returns an error when a command fails.” It returns a list that contains the error at that position; only queue-time failures reject in ioredis.
  • Expecting rollback — check every element of the EXEC result.
  • WATCH on a shared connection — oversells (proven).
  • No retry (or unbounded retry) when EXEC is null — silently lost updates or a livelock; retry a few times with a small random delay.
  • Reading the value before WATCH — the check must come after WATCH, or the value can change unnoticed.
  • Using WATCH where a single DECR would do.

Real-world example

Redis’s own documentation presents WATCH as optimistic locking “very similar to a check-and-set (CAS) operation” and tells the client to retry when EXEC fails (Transactions).

What does a Redis command return while inside a MULTI block?
What happens when EXEC is called and a watched key was changed by another client?
A runtime error in one command inside EXEC (INCR on a string) causes:
A command with the wrong number of arguments is sent between MULTI and EXEC. What does EXEC do?
Why did 20 buyers sharing ONE ioredis connection with WATCH sell 20 of 10 items?
Which command cancels a queued transaction without executing it?