writing

Redis Atomic Operations: Understanding Atomicity, Concurrency, and Race Conditions

Ranjit KhanalSeptember 24, 20267 min read
redisconcurrencydistributed systemsbackend

Redis is often described as single-threaded, and this leads to a common assumption:

"If Redis is single-threaded, everything I do with Redis is automatically atomic."

That statement is only partially true.

A single Redis command executes without another Redis command interleaving with it. But once an operation involves multiple commands, application-side logic, or multiple clients, race conditions can still appear.

Understanding this distinction matters when Redis is used for:

  • counters
  • rate limiting
  • distributed locks
  • inventory
  • booking systems
  • idempotency
  • caching
  • coordination between workers

The key question is therefore not simply "is Redis atomic?" It's:

"Which sequence of operations is Redis executing atomically?"

1. What Does Atomic Mean?

Suppose we have:

counter = 10

and two clients want to increment it. A naive implementation might do:

GET counter
counter = counter + 1
SET counter

With two clients:

Client A              Client B
 
GET → 10
                       GET → 10
SET → 11
                       SET → 11

The expected result was 12, but we ended with 11. This is a lost update.

The problem isn't that Redis executed two commands simultaneously. The problem is that the entire read-modify-write operation wasn't atomic.

2. Redis Commands Are Atomic

Now consider:

INCR counter

INCR performs the entire increment as one Redis command:

Client A → INCR counter
Client B → INCR counter
 
Redis processes commands sequentially:
 
INCR → 11
INCR → 12

There is no point where another Redis command can sneak into the middle of INCR. Therefore, INCR counter is atomic.

This is one of the most important Redis concepts: Redis command atomicity does not mean your application workflow is automatically atomic.

3. The Difference Between Atomic Commands and Atomic Workflows

Consider a booking system. You might want to:

  1. Check whether a technician is available.
  2. Assign the technician.
  3. Change the booking status.

You could write:

GET technician:123:status
SET technician:123:status busy
SET booking:456:status confirmed

Each individual Redis command is atomic. But the workflow is not — another worker could execute between those commands:

Worker A                   Worker B
 
GET status → available
                            GET status → available
SET status → busy
                            SET status → busy
SET booking A confirmed
                            SET booking B confirmed

Now two bookings believe they successfully acquired the same technician. This is where Redis atomicity becomes a distributed-systems problem rather than merely a Redis API problem.

4. SETNX: Atomic Check-and-Set

Redis provides primitives specifically designed for this type of coordination:

SET lock:technician:123 worker-A NX EX 30

NX means: only set the key if it does not already exist. This combines "does the key exist?" and "create the key" into one atomic Redis operation.

Without NX, you might do:

EXISTS lock:technician:123
SET lock:technician:123 worker-A

which creates a race. With NX, Redis performs the conditional operation atomically.

5. Why GET + SET Is Dangerous

This pattern appears everywhere:

const value = await redis.get("counter")
const next = Number(value) + 1
await redis.set("counter", next)

It looks harmless, but the actual operation is GET, then an application-side calculation, then SET. Redis cannot make those three steps atomic just because each command is individually atomic.

If multiple Node.js processes execute this code:

Process A                  Process B
 
GET → 10
                            GET → 10
calculate 11
                            calculate 11
SET → 11
                            SET → 11

The update is lost. Instead:

await redis.incr("counter")

turns the entire state transition into one Redis command.

6. Atomicity With MULTI/EXEC

Redis also provides transactions:

MULTI
INCR user:123:requests
EXPIRE user:123:requests 60
EXEC

The commands queued between MULTI and EXEC execute together without another Redis command being processed in the middle of that execution.

But there's an important distinction: Redis transactions are not the same thing as SQL transactions. MULTI/EXEC does not provide arbitrary rollback semantics. If a command executed during EXEC produces an application-level error, Redis doesn't restore the previous state like a traditional transactional database rollback.

Think of it more as:

MULTI → queue commands → EXEC → execute queued commands sequentially

rather than:

BEGIN → execute → something failed → ROLLBACK everything

7. WATCH: Optimistic Concurrency

Sometimes you need to perform a conditional update based on the current value, for example:

balance = 100
if balance >= 50:
    balance = balance - 50

A simple GET then SET has a race condition. Redis provides WATCH for optimistic concurrency control:

WATCH account:123:balance
GET account:123:balance
 
MULTI
SET account:123:balance 50
EXEC

If another client modifies the watched key before EXEC, Redis aborts the transaction. The application can then read again, recalculate, and retry.

This is fundamentally different from a lock. You're saying "proceed only if the state I observed hasn't changed," rather than "nobody else is allowed to modify this state."

8. Lua Scripts: Make the Decision and Mutation One Operation

Sometimes Redis commands aren't enough. Imagine:

if stock > 0:
    stock--
    create reservation

You need read, check, and write to happen as one atomic unit. A Lua script can execute this logic inside Redis:

local stock = redis.call("GET", KEYS[1])
 
if tonumber(stock) > 0 then
    redis.call("DECR", KEYS[1])
    return 1
end
 
return 0

From the client's perspective:

application → EVAL → Redis (read, check, decrement) → result

The important part is that the logic runs inside Redis, rather than requiring multiple round trips between your application and Redis.

9. The Network Round Trip Is Part of the Problem

This is an important mental model. Consider:

const value = await redis.get(key)
const result = calculate(value)
await redis.set(key, result)

The execution isn't simply GET → SET. It's more like:

Application → GET → Redis → response → Application
Application → calculation
Application → SET → Redis

During that round trip, another client is free to modify the key. A Lua script changes the boundary:

Application → EVAL → Redis (GET, CHECK, SET) → Response

The critical state transition stays inside Redis.

10. Atomicity ≠ Isolation ≠ Durability

These concepts are easy to mix together.

  • Atomicity — an operation happens as one indivisible unit (INCR).
  • Isolation — concurrent operations don't observe problematic intermediate states.
  • Durability — data survives failures according to the configured persistence guarantees.

Redis can provide atomic execution for commands and certain command groups, but that doesn't mean every Redis workflow automatically has all the transactional properties you might expect from a relational database. This distinction becomes especially important when Redis is being used as more than a cache.

11. A Mental Model for Designing Redis Operations

When designing a Redis operation, ask these questions in order.

Can this be expressed as one Redis command? If yes, prefer that: INCR, DECR, SET ... NX, HINCRBY, SADD, ZADD.

Do multiple commands need to execute together? Consider MULTI/EXEC.

Does the operation depend on a value that another client might change? Consider WATCH, or a Lua script.

Can the decision and mutation be performed entirely inside Redis? If yes, Lua scripting can often remove the race window.

12. The Most Important Lesson

The deepest Redis atomicity lesson isn't "Redis is single-threaded." It's this:

Atomicity is determined by the boundary of the operation.

INCR counter is one atomic operation. GET, calculate, SET is not. And GET, check condition, SET is not automatically atomic just because every individual Redis command is atomic.

When designing concurrent systems, identify the state transition first, then decide where that transition needs to become indivisible. That's the point where Redis stops being "just a cache" and starts becoming a coordination primitive for distributed systems.

Conclusion

Redis's single-threaded execution model guarantees that individual commands run atomically, but it says nothing about the workflows built on top of them. The moment a decision spans more than one command, one network round trip, or more than one client, the atomicity guarantee ends at the command boundary.

So the useful question when designing a Redis-backed system isn't "is Redis atomic?" It's "what exactly needs to happen atomically, and where does that boundary sit?" Once that's answered, the right tool tends to follow on its own: a single command like INCR or SET ... NX when the operation fits in one call, MULTI/EXEC when several commands need to execute together, WATCH when a decision depends on a value another client might change, and a Lua script when the read, check, and write all need to happen as one indivisible step inside Redis itself.

Treating Redis as a coordination primitive rather than just a cache starts with getting that boundary right.

← back to writing