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);
}
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 ❌
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)
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();
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
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;
eval
tells Redis to run the script1
is the number of keys we’re passing infailureKey
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!!: 🤝
Top comments (1)
Have you ever used Lua scripting in production? — I’d love to hear your experience!