Lists as Queues & Stacks
Verified on Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. Blocking commands (BLPOP,BLMOVE) cannot be replayed by the course harness with timeout0, so their behaviour is shown by the@runioredis scripts and by the timed transcripts below.
FIFO Queue — LPUSH + RPOP
Section titled “FIFO Queue — LPUSH + RPOP”A queue processes items in first-in, first-out order. With a Redis list: producers call LPUSH (push to head), consumers call RPOP (pop from tail). Because LPUSH always inserts at the head, the oldest item is always at the tail — exactly where RPOP reads from.
127.0.0.1:6379> LPUSH jobs "job:1"(integer) 1127.0.0.1:6379> LPUSH jobs "job:2"(integer) 2127.0.0.1:6379> LPUSH jobs "job:3"(integer) 3127.0.0.1:6379> RPOP jobs"job:1"127.0.0.1:6379> RPOP jobs"job:2"127.0.0.1:6379> RPOP jobs"job:3"127.0.0.1:6379> RPOP jobs(nil)job:1 was pushed first, so it comes out first — FIFO. An empty queue answers (nil) immediately; you would have to poll. (RPOP jobs 2 pops up to two at once since 6.2.)
LPUSH jobs "job:1"
LPUSH jobs "job:2"
LPUSH jobs "job:3"
RPOP jobs
RPOP jobsStack — LPUSH + LPOP
Section titled “Stack — LPUSH + LPOP”A stack processes items in last-in, first-out order. Both push and pop happen at the same end (the head of the list), so the most recently pushed item is always the next one out.
127.0.0.1:6379> DEL stack(integer) 0127.0.0.1:6379> LPUSH stack "frame:1"(integer) 1127.0.0.1:6379> LPUSH stack "frame:2"(integer) 2127.0.0.1:6379> LPUSH stack "frame:3"(integer) 3127.0.0.1:6379> LPOP stack"frame:3"127.0.0.1:6379> LPOP stack"frame:2"DEL stack
LPUSH stack "frame:1"
LPUSH stack "frame:2"
LPUSH stack "frame:3"
LPOP stack
LPOP stackLMOVE — atomically move between lists
Section titled “LMOVE — atomically move between lists”LMOVE source destination LEFT|RIGHT LEFT|RIGHT pops from one end of source and pushes to one end of destination in a single atomic step (Redis 6.2+). This is the classic building block of a reliable queue: a job moves from pending to processing in one step, so there is no instant at which it is in neither list.
| Old command | Equivalent |
|---|---|
RPOPLPUSH src dst (deprecated since 6.2) | LMOVE src dst RIGHT LEFT |
BRPOPLPUSH src dst timeout (deprecated) | BLMOVE src dst RIGHT LEFT timeout |
127.0.0.1:6379> DEL pending processing(integer) 0127.0.0.1:6379> LPUSH pending "task:A" "task:B" "task:C"(integer) 3127.0.0.1:6379> LMOVE pending processing RIGHT LEFT"task:A"127.0.0.1:6379> LRANGE pending 0 -11) "task:C"2) "task:B"127.0.0.1:6379> LRANGE processing 0 -11) "task:A"127.0.0.1:6379> LREM processing 1 "task:A"(integer) 1127.0.0.1:6379> LRANGE processing 0 -1(empty array)127.0.0.1:6379> LMOVE pending processing RIGHT LEFT"task:B"127.0.0.1:6379> LMOVE processing pending LEFT RIGHT"task:B"127.0.0.1:6379> LRANGE pending 0 -11) "task:C"2) "task:B"task:A was pushed first, so with LPUSH it sits at the tail. LMOVE pending processing RIGHT LEFT takes the tail (the oldest) and puts it at the head of processing. Then LREM processing 1 "task:A" is the acknowledgement: the job is finished, forget it. The last two moves show a requeue: task:B was taken, and LMOVE processing pending LEFT RIGHT put it back at the tail of pending, where it is the next job out again.
DEL pending processing
LPUSH pending "task:A" "task:B" "task:C"
LMOVE pending processing RIGHT LEFT
LRANGE pending 0 -1
LRANGE processing 0 -1Blocking pop — BLPOP, BRPOP and BLMOVE
Section titled “Blocking pop — BLPOP, BRPOP and BLMOVE”BLPOP key [key ...] timeout blocks the connection until an element is available, then pops from the first non-empty list. BRPOP does the same from the tail; BLMOVE is the blocking LMOVE. A timeout of 0 means wait indefinitely; any other value is in seconds. On an empty list they answer (nil) after the timeout (the interactive CLI prints the elapsed time):
127.0.0.1:6379> BLPOP emptyq 1(nil)(1.04s)127.0.0.1:6379> LPUSH jobs a(integer) 1127.0.0.1:6379> BRPOP jobs 11) "jobs"2) "a"Note that the reply names the list it popped from ("jobs") as well as the value — needed when you block on several lists at once. In a worker you run the blocking call in a loop; when a producer calls LPUSH, the blocked call returns immediately, no polling required.
A blocked connection is a busy connection. A client that has sent BLPOP cannot do anything else until it returns. We put a PING behind a 1-second BLPOP on the same connection, then on another one:
// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const shared = new Redis(url);const listener = shared.duplicate(); // a second connection for blocking callsawait shared.del('b1:wait');
// Blocking on the SAME connection stalls everything queued behind itlet t = performance.now();const blocked = shared.blpop('b1:wait', 1); // nothing to pop: blocks for 1 sconst ping = shared.ping().then(() => performance.now() - t); // queued behind BLPOPawait blocked;console.log(`PING behind a BLPOP on the same connection: ${(await ping).toFixed(0)} ms`);
// On its own connection the rest of the app is untouchedt = performance.now();const blocked2 = listener.blpop('b1:wait', 1);const pingMs = await shared.ping().then(() => performance.now() - t);console.log(`PING while another connection blocks: ${pingMs.toFixed(1)} ms`);
// A push wakes the blocked connection immediatelysetTimeout(() => shared.lpush('b1:wait', 'wake-up'), 100);t = performance.now();const got = await blocked2; // still waiting from aboveconsole.log('blocked worker got', got?.[1], 'after', (performance.now() - t).toFixed(0), 'ms (push was at ~100 ms)');
await shared.del('b1:wait');shared.disconnect(); listener.disconnect();Real output: PING behind a BLPOP on the same connection: 1023 ms, PING while another connection blocks: 2.4 ms, blocked worker got wake-up after 106 ms (push was at ~100 ms). So: give every blocking worker its own connection (duplicate()), never one from your shared pool or the connection your web handlers use.
The reliable queue: BLMOVE + processing list + reaper
Section titled “The reliable queue: BLMOVE + processing list + reaper”Plain BRPOP versus BLMOVE into a processing list, driven by the same crash (“the worker died right after taking a job”):
// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const producer = new Redis(url);const worker = new Redis(url); // blocking commands get their own connectionconst K = { q: 'b1:queue', proc: 'b1:queue:processing', plain: 'b1:plainq' };await producer.del(K.q, K.proc, K.plain);
// ---------- 1. plain BRPOP: a crash after the pop loses the job ----------await producer.lpush(K.plain, 'job-1', 'job-2', 'job-3'); // job-1 is at the tail = oldestconst popped = await worker.brpop(K.plain, 1); // [key, value]console.log('BRPOP took', popped?.[1], '... worker crashes before finishing');const remaining = await producer.lrange(K.plain, 0, -1);console.log(' pending:', remaining, '| job-1 still in the list?', remaining.includes('job-1'));
// ---------- 2. reliable: BLMOVE pending -> processing, ack with LREM ----------await producer.lpush(K.q, 'job-1', 'job-2', 'job-3');const first = await worker.blmove(K.q, K.proc, 'RIGHT', 'LEFT', 1); // atomic pop + pushconsole.log('BLMOVE took', first, '... worker crashes before LREM');console.log(' pending:', await producer.lrange(K.q, 0, -1), '| processing:', await producer.lrange(K.proc, 0, -1));
// reaper (runs on start-up, or on a timer): put unfinished work back at the front of the queuelet requeued = 0;while (await producer.lmove(K.proc, K.q, 'LEFT', 'RIGHT')) requeued++;console.log('reaper requeued', requeued, '| pending now:', await producer.lrange(K.q, 0, -1));
// a healthy worker: move, do the work, then acknowledge with LREMconst done: string[] = [];for (;;) { const job = await worker.blmove(K.q, K.proc, 'RIGHT', 'LEFT', 1); if (job === null) break; // timeout: queue drained done.push(job); // ...do the work... await producer.lrem(K.proc, 1, job); // ack}console.log('processed in order:', done, '| processing left:', await producer.llen(K.proc));
await producer.del(K.q, K.proc, K.plain);producer.disconnect(); worker.disconnect();Real output:
BRPOP took job-1 ... worker crashes before finishing pending: [ 'job-3', 'job-2' ] | job-1 still in the list? falseBLMOVE took job-1 ... worker crashes before LREM pending: [ 'job-3', 'job-2' ] | processing: [ 'job-1' ]reaper requeued 1 | pending now: [ 'job-3', 'job-2', 'job-1' ]processed in order: [ 'job-1', 'job-2', 'job-3' ] | processing left: 0With BRPOP, job-1 is nowhere — lost. With BLMOVE, it sits in processing and the reaper puts it back at the tail, so it runs next; the healthy worker then finishes all three in order.
What this pattern does not give you. The reaper above requeues everything in processing, which is safe only at start-up or when you know all workers are dead. In a live fleet you cannot tell “stuck” from “slow”: a list item has no timestamp and no owner, so a reaper that runs while a worker is mid-job will hand the same job to two workers (at-least-once delivery — make jobs idempotent). There is also no retry counter, no delay, no dead-letter queue, no visibility of who holds what. The moment you need those, use Streams with consumer groups, which keep a pending-entries list with idle time and support XAUTOCLAIM (Streams, Consumer Groups), or a purpose-built job library.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant writes a background worker “using the production-grade BLPOP pattern”:
🐛 Spot the bug
const redis = new Redis(process.env.REDIS_URL!); // same client used by the HTTP handlers
async function worker() {while (true) { const [, job] = (await redis.blpop('jobs', 0))!; await handle(JSON.parse(job)); // send the invoice email}}Show the answer
Two real bugs. First, BLPOP removes the job before handle runs: if handle throws, or the process is killed or redeployed, the invoice is gone with no trace (we reproduced the loss with BRPOP above: job-1 still in the list? false). Use BLMOVE into a processing list and LREM after success, plus a reaper. Second, the worker blocks the client it shares with the HTTP handlers, so their commands queue behind a BLPOP 0 for as long as the queue is empty — measured PING latency 1,023 ms on a shared connection versus 2.4 ms on a dedicated one. The fix:
const workerConn = redis.duplicate(); // dedicated blocking connection// BLMOVE jobs -> jobs:processing, handle(), then LREM jobs:processing 1 <job> (see the runnable script above)Mental Model
Section titled “Mental Model”A Redis list queue is a conveyor belt with one exit. LPUSH/RPOP moves parcels along it; BLPOP is a worker standing at the exit waiting. The belt has no memory of who took a parcel. To make the belt reliable you add a second belt (processing) and always move a parcel across atomically before working on it — so every parcel is on exactly one belt at every instant — and you delete it only after the work is done. Anything richer than that (who, since when, how many tries) is what Streams are for.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
BLPOP/BRPOP (blocking pop) | No polling loop to write; a worker wakes immediately when work arrives | A blocking connection per worker; the job is lost if the worker dies after the pop |
| Redis List as a queue | Simple to set up, low latency, no separate broker to install | No built-in retry, dead-letter queue, delay or visibility; at-least-once at best |
LMOVE/BLMOVE to a processing list + LREM ack | No job loss if a worker crashes mid-processing | You write the ack and the reaper; the reaper cannot distinguish “slow” from “dead”, so jobs must be idempotent |
| Streams + consumer group instead | Per-message ownership, idle time, XAUTOCLAIM, retries counted | More concepts and commands; larger memory per entry |
Common Misconceptions
Section titled “Common Misconceptions”- “
BLPOPis a reliable queue.” It is a blocking queue. Reliability comes fromBLMOVE+ ack, or from Streams. - “A blocked connection is idle.” It is busy: everything else you send on it waits (1,023 ms
PINGmeasured). - “The reaper makes this exactly-once.” It makes it at-least-once; a slow (not dead) worker’s job can be duplicated.
- “
RPOPLPUSHis the current command.” It is deprecated since 6.2 in favour ofLMOVE.
Common mistakes
Section titled “Common mistakes”- Moving a job to a processing list with
LMOVE/BLMOVEbut never clearing it once processed — the processing list grows without bound, effectively a memory leak. Ack withLREM processing 1 job. - Using a Redis List as a queue when you need strict retry, dead-letter queues, or delivery guarantees — a Redis List has none of these built in; you would have to build all of it yourself.
- Running
BLPOPon a shared connection — every other command on it stalls until the pop returns. - Letting a list grow unbounded with no
LTRIM— if consumers fall behind producers, the list grows without limit until memory runs out.
Real-world example
Sidekiq, a popular Ruby background-job library, documents using Redis’s data structures — including Lists — as its job storage, with worker processes pulling jobs off Redis much like the
BLPOPpattern in this lesson (Sidekiq FAQ, Sidekiq Wiki).