DEV Community

Cover image for Fixing Race Conditions in Redis Counters: Why Lua Scripting Is the Key to Atomicity and Reliability
Ali nazari
Ali nazari

Posted on

Fixing Race Conditions in Redis Counters: Why Lua Scripting Is the Key to Atomicity and Reliability

If you've ever built rate limiting or login throttling with Redis, chances are you've used INCR and EXPIRE. Seems easy, right?

But if you're doing them in two separate steps, you might be in for a surprise—especially under high load.

Let’s talk about the subtle (and annoying) race condition this causes, why plain Redis transactions don’t fix it, and how Lua scripting totally saves the day.

The Situation: Tracking Failures Over Time

Let’s say you’re trying to block brute-force login attempts. So every time a login fails, you increment a counter:

const count = unwrap(await redis.fire("incr", failureKey)) as number;
if (count === 1) {
  await redis.fire("expire", failureKey, WINDOW_SEC);
}
Enter fullscreen mode Exit fullscreen mode

What’s going on here:

  • failureKey might be something like login:fail:user123
  • WINDOW_SEC is how long the counter should live (like 60 seconds)
  • If it’s the first failure, you kick off the timer by setting a TTL

It works… mostly.

The Sneaky Race Condition 😬

This setup breaks under concurrency. Here’s how:

If two requests from the same IP fail at the same time, both of them run this logic in parallel against the same key

The race is about concurrent access to the same user/IP key.

Timeline of the Race

[Request A] --- redis.incr → returns 1
[Request B] --- redis.incr → returns 2
[Request A] --- count === 1 → sets EXPIRE to 60s ✅
[Request B] --- count !== 1 → skips EXPIRE ❌
Enter fullscreen mode Exit fullscreen mode

So far, it seems okay… TTL was set once, right?

But imagine this order:

[Request B] --- redis.incr → returns 1
[Request A] --- redis.incr → returns 2
[Request B] --- sets EXPIRE to 60s ✅
[Request A] --- sets EXPIRE again to 60s 🕓 (resets timer)
Enter fullscreen mode Exit fullscreen mode

Now the TTL starts after 2 failures, not 1. You've allowed extra time unintentionally.

That’s a classic race condition—and these are exactly the kind of bugs you don’t want to debug in production at 2AM.

Can’t We Just Use Transactions? 🤔

That’s a good thought—and Redis does support transactions via MULTI and EXEC. But here’s the catch:

await redis.multi()
  .incr(failureKey)
  .expire(failureKey, WINDOW_SEC)
  .exec();
Enter fullscreen mode Exit fullscreen mode

This groups the commands together, but not conditionally. Redis will always run both commands—INCR and EXPIRE—no matter what. So you can’t say “expire only if the count is 1.”

And while Redis MULTI ensures the commands run one after another, they still aren’t atomic in the sense that no logic runs between them. Another client can sneak in between your INCR and EXPIRE, and mess things up.

So yeah—transactions alone don’t solve this. You need logic + atomic execution.

Lua Scripting Saves the Day ✨

Redis has a built-in feature for this: Lua scripts.

They let you run multiple commands on the Redis server as a single, atomic operation. Here’s the script:

local count = redis.call("INCR", KEYS[1])
if count == 1 then
  redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return count
Enter fullscreen mode Exit fullscreen mode

What’s happening:

  • We increment the key
  • If it’s the first increment, we set the expiration
  • Then return the new count

All of this happens as one atomic command. No other Redis client can sneak in during this execution. Problem solved.

Why Lua Rocks for This

  • No more race conditions ✅
  • Fewer round-trips to Redis
  • Cleaner logic—you move complexity server-side

Basically, it's safer and faster.

How to Use It in Node.js

If you're using something like ioredis, here’s how you'd fire off that Lua script:

const luaScript = `
  local count = redis.call("INCR", KEYS[1])
  if count == 1 then
    redis.call("EXPIRE", KEYS[1], ARGV[1])
  end
  return count
`;

const count = unwrap(
  await redis.fire("eval", luaScript, 1, failureKey, WINDOW_SEC),
) as number;

Enter fullscreen mode Exit fullscreen mode
  • eval tells Redis to run the script

  • 1 is the number of keys we’re passing in

  • failureKey goes into KEYS[1], WINDOW_SEC into ARGV[1]

Any time you're chaining Redis commands where order and timing matter, and especially when things can get concurrent—Lua scripting is your go-to

It makes your logic bulletproof, saves you network hops, and helps you sleep at night knowing your counters won't glitch out under pressure.


Hey! I recently created a tool called express-admin-honeypot.

Feel free to check it out, and if you like it, consider leaving a generous star on my GitHub! 🌟


Let's connect!!: 🤝

LinkedIn
GitHub

Top comments (1)

Collapse
 
silentwatcher_95 profile image
Ali nazari

Have you ever used Lua scripting in production? — I’d love to hear your experience!