Blocking List Operations (BLPOP, BRPOP)

A plain LPOP or RPOP on an empty list just returns (nil) immediately — if you want to build a job queue, you’d have to poll the list over and over, wasting CPU and adding latency. BLPOP and BRPOP solve this: they behave exactly like LPOP/RPOP when the list has data, but if the list is empty they make the client wait until an element shows up (or a timeout passes) instead of returning right away. This is the foundation for building reliable, low-latency work queues, task pipelines, and simple producer/consumer patterns directly on top of Redis lists.

Overview / How it works

BLPOP and BRPOP are the blocking counterparts of LPOP (pop from the head/left) and RPOP (pop from the tail/right). Each command takes one or more list keys plus a timeout. Redis checks the given keys, in the order you list them, for the first one that has at least one element. If it finds one, it pops from that list immediately and returns — behaving exactly like the non-blocking version, with no waiting at all. Only when every given key is empty or missing does blocking actually happen.

Understanding what “blocking” means here requires understanding that Redis’s command execution is single-threaded: only one command runs at a time on the main thread, which is why a command like LPUSH is atomic and can never be interleaved with another client’s write. A blocking command doesn’t violate this — instead, when a client issues BLPOP against an empty key, the server parks that client’s connection in a per-key waiting list and immediately moves on to serve other clients. The blocked client consumes no CPU while it waits; it’s simply not scheduled for a reply yet. The server keeps processing every other command from every other connection normally.

When some other client later runs LPUSH, RPUSH, LMOVE, or RPOPLPUSH against a key that has waiting clients, Redis wakes the longest-waiting client for that key (first-in, first-out among waiters) and delivers the newly pushed element to it as part of the same atomic step — the value is popped and handed to the waiting client without ever being visible to any other client in between. This is what makes BLPOP safe for building a queue: two consumers blocked on the same key can never both receive the same pushed item.

If the timeout elapses with nothing pushed, the server sends a null (nil) reply back to the still-waiting client, and the command returns as if the list were empty. A timeout of 0 means “block forever” — there is no maximum wait in that case.

Syntax

BLPOP key [key ...] timeout
BRPOP key [key ...] timeout
Argument Meaning
key [key ...] One or more list keys to watch, checked in the order given. The first key (in that order) that has an element wins.
timeout Maximum seconds to block, as an integer or a fraction like 0.1 (fractional timeouts require Redis 6.0+). 0 means block indefinitely.

BLPOP pops from the head of the winning list (same end as LPOP); BRPOP pops from the tail (same end as RPOP). On success both return a two-element array: the name of the key that had data, followed by the popped element. On timeout, both return a null array reply, printed by redis-cli as (nil).

Command Time Complexity
BLPOP / BRPOP O(N) where N is the number of keys given (Redis has to check each key); the actual pop is O(1)
LPUSH / RPUSH O(1) per element pushed
LPOP / RPOP O(1) (O(N) if popping a count of N elements at once)

Examples

Example 1: Basic queue consumption

RPUSH tasks:queue "send-email" "resize-image"
BLPOP tasks:queue 5
BLPOP tasks:queue 5
BLPOP tasks:queue 1

Output:

(integer) 2
1) "tasks:queue"
2) "send-email"
1) "tasks:queue"
2) "resize-image"
(nil)

The first two BLPOP calls return instantly because tasks:queue already has elements — there’s no actual waiting. Once the list is empty, the third call has nothing to pop, so the client blocks for up to 1 second and then gets back (nil) when the timeout expires.

Example 2: Watching multiple keys (priority order)

RPUSH queue:priority:low "low-task-1"
BRPOP queue:priority:high queue:priority:low 5

Output:

(integer) 1
1) "queue:priority:low"
2) "low-task-1"

queue:priority:high doesn’t exist yet, so Redis moves on to check queue:priority:low in the same call, finds an element, and returns it immediately — no blocking occurs. This is the pattern for a simple priority queue: list the higher-priority key first, and it always wins whenever it has data.

Example 3: FIFO job queue with a producer and a consumer

LPUSH notifications:queue "welcome-email:user42"
LPUSH notifications:queue "password-reset:user17"
BRPOP notifications:queue 2
BRPOP notifications:queue 2
BRPOP notifications:queue 1

Output:

(integer) 1
(integer) 2
1) "notifications:queue"
2) "welcome-email:user42"
1) "notifications:queue"
2) "password-reset:user17"
(nil)

The producer always pushes new jobs onto the head with LPUSH; the consumer always pops from the tail with BRPOP. Because the first job pushed ends up furthest from the head, it’s the first one popped from the tail — giving first-in-first-out order. Once the queue drains, the last BRPOP waits out its 1-second timeout and returns (nil).

How it works step by step

For a call like BLPOP jobs:queue 5 issued against an empty jobs:queue:

  • 1. The server checks jobs:queue. It’s empty (or doesn’t exist), so no immediate pop is possible.
  • 2. The client connection is parked in an internal waiting list associated with the key jobs:queue. No reply is sent yet, and the client sits idle without spending CPU.
  • 3. The main event loop continues serving every other client’s commands as normal — a blocked client never stalls the rest of the server.
  • 4. When another client runs LPUSH jobs:queue "some-job", Redis notices there’s a client waiting on jobs:queue, immediately pops the value that was just pushed, and delivers it to the longest-waiting client as a single atomic step.
  • 5. If 5 seconds pass with no push, Redis instead sends a null reply to the client and removes it from the waiting list.

Common Mistakes

Mistake: assuming a timeout of 0 is a short or “no-op” wait. A timeout of 0 means block forever, which can leave a connection hanging indefinitely if nothing is ever pushed.

BLPOP session:queue:urgent 0

Output:

(blocks indefinitely -- no reply is sent until an element is pushed or the connection is closed)

Prefer a bounded timeout (even a generous one like 30 or 60 seconds) in application code, then re-issue the call in a loop. This lets your client periodically check for shutdown signals or connection health instead of blocking forever.

Mistake: calling a blocking list command on a key that holds a different type. Every Redis key has exactly one type, and list commands against a non-list key fail with WRONGTYPE.

SET session:token:99 "abc123"
BLPOP session:token:99 1

Output:

OK
(error) WRONGTYPE Operation against a key holding the wrong kind of value

Keep your key namespaces disciplined (for example, only ever store lists under a *:queue prefix) so this class of mistake is easy to spot.

Mistake: expecting BLPOP to actually block inside MULTI/EXEC. Because a transaction must execute all its queued commands as one atomic, non-interruptible unit, Redis cannot let a command inside it pause and wait — so a blocking command used inside MULTI/EXEC behaves as if the timeout were already 0-and-expired: it returns immediately with a nil reply if the list is empty, rather than actually waiting.

MULTI
BLPOP jobs:queue 5
EXEC

Output:

OK
QUEUED
1) (nil)

Even though a 5-second timeout was given and jobs:queue is empty, EXEC returns instantly with (nil) for that command instead of waiting. If you need blocking behavior, issue BLPOP outside of a transaction.

Best Practices

  • Use BLPOP/BRPOP instead of polling a list with LLEN or LPOP in a loop — polling wastes CPU and adds latency proportional to your poll interval.
  • Standardize on one push/pop direction pair (for example, always LPUSH to produce, BRPOP to consume) so your queue is reliably FIFO.
  • Use a bounded timeout rather than 0 in production clients, so your application can periodically reconnect, log a heartbeat, or check for shutdown signals.
  • When you need guaranteed delivery (no lost jobs if a consumer crashes mid-processing), use BLMOVE (or the older BRPOPLPUSH) to atomically move the popped item into a separate “processing” list instead of plain BLPOP, which discards the value the moment it’s popped.
  • List keys in priority order when watching multiple keys in one call — the first key with data always wins.
  • Never call blocking commands inside MULTI/EXEC or a Lua script expecting them to wait — they won’t.
  • Remember that every blocked client still holds an open connection and counts against your server’s maxclients limit; size your connection pool accordingly if you have many concurrent consumers.

Practice Exercises

  • 1. Push three job names onto a list called jobs:queue with RPUSH, then call BRPOP jobs:queue 5 three times to drain it one at a time. Predict — then check — what a fourth BRPOP jobs:queue 1 call returns.
  • 2. Create two lists, jobs:high and jobs:low, and push an item only onto jobs:low. Call BLPOP jobs:high jobs:low 5 and work out which key name is returned in the reply and why.
  • 3. Use SET to create a plain string key, then try running BLPOP against it with a short timeout. Note the exact error text and explain, in your own words, why Redis enforces this.

Summary

  • BLPOP pops from the head (like LPOP); BRPOP pops from the tail (like RPOP) — both block only when every given key is empty.
  • When multiple keys are given, Redis checks them in the order listed and returns from the first one with data — no blocking occurs if any key already has elements.
  • Blocking parks the client connection without using CPU; the single-threaded server keeps serving every other client normally while a client waits.
  • A pushed value is delivered atomically to the longest-waiting client — no two blocked consumers can ever receive the same pushed element.
  • A timeout of 0 blocks forever; blocking commands do not actually block inside MULTI/EXEC or Lua scripts.
  • For guaranteed-delivery queues, prefer BLMOVE/BRPOPLPUSH over plain BLPOP so a popped item lands in a processing list instead of disappearing.