Redis returns null from EXEC when a watched key was modified between WATCH and EXEC, indicating the optimistic lock failed and the transaction was aborted. This is expected behavior for Redis optimistic locking, ensuring data consistency when multiple clients access the same keys. This guide explains why EXEC returns null, how to implement proper retry logic, and strategies to minimize transaction conflicts.
When Redis returns null from the EXEC command, it means at least one key you watched with WATCH was modified after the WATCH call but before EXEC attempted to execute the transaction. This is Redis's optimistic locking mechanism working as designed: WATCH monitors keys for changes, and if any modification occurs—whether from another client, expiration, eviction, or even your own commands outside the transaction—Redis aborts the entire MULTI/EXEC transaction to prevent working with stale data. The null return value is not an error but a signal that you need to retry the operation. Redis uses this check-and-set (CAS) behavior to guarantee that your transaction only executes if all watched keys remain unchanged, making it safe to perform read-modify-write operations in a multi-client environment without explicit locks.
Test the basic WATCH/MULTI/EXEC flow to verify the null response and understand when it occurs:
# In one Redis client, watch a key and start a transaction
redis-cli
WATCH mykey
GET mykey
MULTI
SET mykey "new-value"
# Don't run EXEC yet
# In a second Redis client, modify the watched key
redis-cli SET mykey "modified-by-client-2"
# Back in the first client, run EXEC
EXEC
# Returns: (nil) because mykey was modifiedThis demonstrates that EXEC returns null when a watched key changes. Check your application logs to confirm this is what's happening in production.
Wrap your WATCH/MULTI/EXEC transaction in a retry loop that handles the null response:
// Node.js with node-redis or ioredis
async function incrementCounter(client, key, maxRetries = 10) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await client.watch(key);
// Read current value
const current = await client.get(key);
const newValue = (parseInt(current || '0', 10) + 1).toString();
// Execute transaction
const multi = client.multi();
multi.set(key, newValue);
const results = await multi.exec();
// Check for null (transaction aborted)
if (results === null) {
console.log(`Attempt ${attempt + 1} failed, retrying...`);
continue;
}
// Success
console.log(`Transaction succeeded on attempt ${attempt + 1}`);
return newValue;
} catch (err) {
await client.unwatch();
throw err;
}
}
throw new Error(`Transaction failed after ${maxRetries} attempts`);
}Always unwatch keys if an exception occurs to avoid leaving watches active. Most client libraries handle this automatically on connection errors.
If you're seeing many retries due to hot keys, add a small delay between attempts to reduce thundering herd:
async function incrementWithBackoff(client, key, maxRetries = 10) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
await client.watch(key);
const current = await client.get(key);
const newValue = (parseInt(current || '0', 10) + 1).toString();
const multi = client.multi();
multi.set(key, newValue);
const results = await multi.exec();
if (results !== null) {
return newValue;
}
// Exponential backoff: 2^attempt ms, max 100ms
const delay = Math.min(Math.pow(2, attempt), 100);
await new Promise(resolve => setTimeout(resolve, delay));
}
throw new Error('Max retries exceeded');
}This spreads out retry attempts and reduces the chance of multiple clients retrying simultaneously.
For simple read-modify-write operations, Lua scripts execute atomically without needing WATCH/MULTI/EXEC:
-- Increment counter atomically (increment.lua)
local current = redis.call('GET', KEYS[1]) or '0'
local newValue = tostring(tonumber(current) + 1)
redis.call('SET', KEYS[1], newValue)
return newValue// Execute the Lua script
const result = await client.eval(
fs.readFileSync('./increment.lua', 'utf8'),
1,
'mycounter'
);Lua scripts are atomic and never fail due to concurrent modifications, making them ideal for operations that don't need complex conditional logic based on multiple keys. They also reduce round trips between client and server.
If one key is heavily contested, split it into multiple shards to distribute load:
// Shard counter across multiple keys
async function incrementShardedCounter(client, baseKey, numShards = 10) {
const shard = Math.floor(Math.random() * numShards);
const shardKey = `${baseKey}:shard:${shard}`;
// Simple INCR, no WATCH needed for single key operations
return await client.incr(shardKey);
}
// Read total count by summing all shards
async function getTotalCount(client, baseKey, numShards = 10) {
const pipeline = client.pipeline();
for (let i = 0; i < numShards; i++) {
pipeline.get(`${baseKey}:shard:${i}`);
}
const results = await pipeline.exec();
return results.reduce((sum, [err, val]) => sum + parseInt(val || '0', 10), 0);
}This approach works well for counters, rate limiters, and other aggregate metrics where exact real-time consistency isn't critical.
The WATCH mechanism in Redis tracks keys at the logical level, not the physical level—this means RENAME, DEL, EXPIRE, and even internal evictions all count as modifications that will cause EXEC to return null. If you're using Redis Cluster, ensure all watched keys and transaction keys hash to the same slot (use hash tags like {user123}) or you'll get CROSSSLOT errors.
For debugging excessive transaction failures, monitor key modification patterns with MONITOR (use sparingly in production) or enable keyspace notifications for the specific keys. In high-throughput scenarios with many competing clients, optimistic locking can lead to livelock where transactions continuously fail—in these cases, consider alternative patterns like Redlock for distributed locking, or restructure your data model to avoid cross-key transactions entirely. Redis 7.0+ also supports functions that can replace complex WATCH/MULTI/EXEC patterns with guaranteed atomic execution.
ERR Unbalanced XREAD list of streams
How to fix "ERR Unbalanced XREAD list" in Redis
ERR syntax error
How to fix "ERR syntax error" in Redis
ConnectionError: Error while reading from socket
ConnectionError: Error while reading from socket in redis-py
ERR unknown command
How to fix ERR unknown command in Redis
Command timed out
How to fix 'Command timed out' in ioredis