Lua Scripting & Pipelining

advanced redis lua pipelining performance

These two get lumped together in interviews and they shouldn’t. They solve completely different problems.

  • Lua scripting = atomicity. Run multi-step logic on the server with no other client interleaving.
  • Pipelining = throughput. Send many independent commands without waiting for replies one by one.

In simple language: Lua is “I need these 5 operations to act like one”. Pipelining is “I have 1000 unrelated SETs and I don’t want to pay 1000 network round-trips”.

Pipelining: cutting round-trips

Every Redis command normally goes: client sends → wait → server processes → server replies → client reads. The bottleneck is the network round-trip time (RTT), not Redis itself.

Pipelining batches commands into one send and reads all replies at the end.

Without pipelining (3 commands, 3 RTTs)
C → SET a 1 → S
C ← OK      ← S
C → SET b 2 → S
C ← OK      ← S
C → SET c 3 → S
C ← OK      ← S
With pipelining (3 commands, 1 RTT)
C → SET a 1 \n SET b 2 \n SET c 3 → S
C ← OK \n OK \n OK           ← S

In code:

// Without pipelining - 1000 round trips
for (let i = 0; i < 1000; i++) await redis.set(`k:${i}`, i);

// With pipelining - 1 round trip
const p = redis.pipeline();
for (let i = 0; i < 1000; i++) p.set(`k:${i}`, i);
await p.exec();

The difference can be 50-100x on a 1ms-RTT link.

Pipelining is NOT atomic

Other clients’ commands can still interleave between yours. Pipelining is purely a transport optimization. If you need atomicity, that’s MULTI/EXEC or Lua.

Lua scripting: atomic server-side logic

EVAL runs a Lua script on the Redis server. While the script runs, nothing else runs. Single-threaded Redis means a Lua script blocks everything else, so the script is atomic from every client’s perspective.

EVAL "return redis.call('GET', KEYS[1])" 1 mykey

Real-world: a rate limiter that checks and increments in one shot.

-- KEYS[1] = "ratelimit:user:42"
-- ARGV[1] = limit (e.g. 100)
-- ARGV[2] = window seconds (e.g. 60)

local current = redis.call("INCR", KEYS[1])
if current == 1 then
  redis.call("EXPIRE", KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
  return 0  -- rejected
end
return 1    -- allowed

Call it from the client:

const allowed = await redis.eval(
  rateLimitScript,
  1,                       // number of keys
  `ratelimit:user:${id}`,  // KEYS[1]
  "100", "60"              // ARGV
);

The classic alternative — GET, check, INCR, EXPIRE in client code — is a race waiting to happen. Lua collapses it into one atomic operation.

EVAL vs EVALSHA

Sending the full script over the wire every call wastes bandwidth. SCRIPT LOAD caches the script on the server and returns a SHA1. Then call it by hash:

SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# "6b1bf486c81ceb7151f807e35e08a8df1e0a3a1f"

EVALSHA 6b1bf486c81ceb7151f807e35e08a8df1e0a3a1f 1 mykey

Most client libraries handle this transparently — they try EVALSHA first, fall back to EVAL with NOSCRIPT error, cache the hash.

When NOT to use Lua

  • Long-running scripts. Anything > a few ms blocks all other clients. Default kill threshold is 5s (lua-time-limit).
  • Network I/O or sleeps — not allowed and bad ideas.
  • Anything that calls many keys you can’t predict — Cluster mode requires all keys to live on the same slot.

Combining both

For batching many independent atomic operations, pipeline a bunch of EVALSHA calls. Best of both worlds:

const p = redis.pipeline();
for (const userId of userIds) {
  p.evalsha(sha, 1, `ratelimit:user:${userId}`, "100", "60");
}
const results = await p.exec();

Quick recap

  • Pipelining = transport-level batching, no atomicity.
  • Lua = server-side atomic execution, blocks everything while running.
  • Keep Lua scripts short. Cache them with SCRIPT LOAD + EVALSHA.
  • They compose: pipeline a stream of EVALSHA calls.