Skip to content

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 timeout 0, so their behaviour is shown by the @run ioredis scripts and by the timed transcripts below.

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.

cli/lists-hashes-list-queues-1.redis
127.0.0.1:6379> LPUSH jobs "job:1"
(integer) 1
127.0.0.1:6379> LPUSH jobs "job:2"
(integer) 2
127.0.0.1:6379> LPUSH jobs "job:3"
(integer) 3
127.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 jobs

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.

cli/lists-hashes-list-queues-2.redis
127.0.0.1:6379> DEL stack
(integer) 0
127.0.0.1:6379> LPUSH stack "frame:1"
(integer) 1
127.0.0.1:6379> LPUSH stack "frame:2"
(integer) 2
127.0.0.1:6379> LPUSH stack "frame:3"
(integer) 3
127.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 stack

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 commandEquivalent
RPOPLPUSH src dst (deprecated since 6.2)LMOVE src dst RIGHT LEFT
BRPOPLPUSH src dst timeout (deprecated)BLMOVE src dst RIGHT LEFT timeout
cli/lists-hashes-list-queues-3.redis
127.0.0.1:6379> DEL pending processing
(integer) 0
127.0.0.1:6379> LPUSH pending "task:A" "task:B" "task:C"
(integer) 3
127.0.0.1:6379> LMOVE pending processing RIGHT LEFT
"task:A"
127.0.0.1:6379> LRANGE pending 0 -1
1) "task:C"
2) "task:B"
127.0.0.1:6379> LRANGE processing 0 -1
1) "task:A"
127.0.0.1:6379> LREM processing 1 "task:A"
(integer) 1
127.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 -1
1) "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 -1

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) 1
127.0.0.1:6379> BRPOP jobs 1
1) "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:

src/lists-hashes-list-queues-blocking.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const shared = new Redis(url);
const listener = shared.duplicate(); // a second connection for blocking calls
await shared.del('b1:wait');
// Blocking on the SAME connection stalls everything queued behind it
let t = performance.now();
const blocked = shared.blpop('b1:wait', 1); // nothing to pop: blocks for 1 s
const ping = shared.ping().then(() => performance.now() - t); // queued behind BLPOP
await 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 untouched
t = 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 immediately
setTimeout(() => shared.lpush('b1:wait', 'wake-up'), 100);
t = performance.now();
const got = await blocked2; // still waiting from above
console.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”):

src/lists-hashes-list-queues-reliable.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const producer = new Redis(url);
const worker = new Redis(url); // blocking commands get their own connection
const 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 = oldest
const 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 + push
console.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 queue
let 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 LREM
const 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? false
BLMOVE 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: 0

With 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.

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)

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.

ChoiceBenefitCost
BLPOP/BRPOP (blocking pop)No polling loop to write; a worker wakes immediately when work arrivesA blocking connection per worker; the job is lost if the worker dies after the pop
Redis List as a queueSimple to set up, low latency, no separate broker to installNo built-in retry, dead-letter queue, delay or visibility; at-least-once at best
LMOVE/BLMOVE to a processing list + LREM ackNo job loss if a worker crashes mid-processingYou write the ack and the reaper; the reaper cannot distinguish “slow” from “dead”, so jobs must be idempotent
Streams + consumer group insteadPer-message ownership, idle time, XAUTOCLAIM, retries countedMore concepts and commands; larger memory per entry
  • “BLPOP is a reliable queue.” It is a blocking queue. Reliability comes from BLMOVE + ack, or from Streams.
  • “A blocked connection is idle.” It is busy: everything else you send on it waits (1,023 ms PING measured).
  • “The reaper makes this exactly-once.” It makes it at-least-once; a slow (not dead) worker’s job can be duplicated.
  • “RPOPLPUSH is the current command.” It is deprecated since 6.2 in favour of LMOVE.
  • Moving a job to a processing list with LMOVE/BLMOVE but never clearing it once processed — the processing list grows without bound, effectively a memory leak. Ack with LREM 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 BLPOP on 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 BLPOP pattern in this lesson (Sidekiq FAQ, Sidekiq Wiki).

Which command pair implements a FIFO queue?
What does BLPOP jobs 0 do when the list is empty?
What does LMOVE (the replacement for RPOPLPUSH) do?
Which command pair implements a stack (LIFO)?
A worker takes a job with BRPOP and is killed before finishing. What happened to the job?
Why should a blocking worker use its own connection?