Redis Is Single-Threaded and Still Has Race Conditions
January 15, 2026
People hear "Redis is single-threaded" and assume that buys them safety for free. It does not. A single command runs atomically. A workflow built out of several commands does not. The race window opens the moment your logic spans more than one round trip.
The canonical example is a rate limiter. You want to allow at most five requests per minute. The logic reads: GET counter, check if it is below 5, then INCR or block. Three lines of pseudocode, two Redis commands, and a race condition sitting between them.
Two clients hit the endpoint at the same instant. Client A calls GET counter and sees 4. Client B calls GET counter and also sees 4. Both branches conclude "still under the limit." Both call SET counter 5. Two requests get allowed, the counter lands at 5, and the limit was silently violated by exactly one. No error, no exception, no log entry. Redis did exactly what you asked it to do.
Retries do not help here. Neither client misbehaved. The bug is structural: the read, the decision, and the write live in separate commands, and Redis is free to interleave commands from different clients between them. Single-threading guarantees that two commands do not run simultaneously. It guarantees nothing about what happens between two commands belonging to the same client.
The fix is to pick a real atomic unit. Three primitives cover almost every case.
Lua scripts via EVAL or EVALSHA. You ship the full read-decide-write logic to the server as one script. Redis runs it end to end with no other command interleaved. The rate limiter becomes a single EVAL call that returns ALLOW or BLOCK. This is the right answer for anything with branching logic, multiple keys, or conditional writes.
WATCH plus MULTI and EXEC. Optimistic concurrency control. You watch the keys you care about, read them, build a transaction, and commit. If any watched key changed between the watch and the exec, the transaction aborts and you retry. Good when contention is rare and you want to keep logic on the client.
SETNX and friends. SET key value NX PX 30000 is the building block for locks, leader election, and claim-once semantics. The command succeeds only if the key did not exist, atomically. It is the smallest useful primitive Redis exposes for "I got here first."
A production failure mode that catches teams: implementing the limiter with INCR and a separate EXPIRE. INCR is atomic, but between INCR and EXPIRE, a crash or a slow client can leave a counter with no TTL. It accumulates forever and locks the user out permanently. The Lua version sets the TTL on first write inside the same script and never desynchronizes.
The mental model is simple. One Redis command is atomic. A workflow is not, unless you make it one.
Redis executes each command atomically, but a workflow built from several commands is not atomic. Two clients can read the same value, decide they are both allowed, and write conflicting results. The fix is to pick the right atomic unit: Lua scripts for multi-step logic, WATCH/MULTI/EXEC for optimistic transactions, and SETNX for simple claim primitives.
Originally posted on LinkedIn. View original.