Using Lists as Queues and Stacks

A Redis List is an ordered collection of strings that you can push onto and pop off of at either end in constant time. That simple property makes lists the natural building block for two of the most common patterns in application design: a queue (first-in, first-out, like a line at a checkout counter) and a stack (last-in, first-out, like a stack of plates). Because every list operation runs atomically on Redis’s single thread, you get safe, race-free queues and stacks with no extra locking, no matter how many clients are pushing and popping at once.

This lesson covers how lists are stored internally, the full command set for pushing, popping, inspecting, and blocking on a list, and the exact pattern production systems use to build a queue that survives a worker crashing mid-job.

Overview / How it works

Internally, a Redis list is stored as a listpack (a compact, contiguous memory encoding) for small lists, and Redis automatically converts it to a quicklist — a doubly linked list of listpack nodes — as the list grows past the configured size and length thresholds. This hybrid structure is why pushing or popping from either end of a list (LPUSH, RPUSH, LPOP, RPOP) is O(1): Redis only has to touch the head or tail node, never walk the whole structure. Accessing or scanning the middle of a list (LINDEX, LRANGE over a large range) is O(N), because Redis has to walk node by node from the nearest end to reach the target position.

Because Redis is single-threaded, every individual list command executes to completion before the next command (from any client) begins. That means LPUSH and LPOP can never interleave mid-operation — you will never see a torn read or a lost push, even under heavy concurrent load, without writing a single line of locking code yourself.

Queue vs. stack: it’s just which end you use

A list has no inherent “queue-ness” or “stack-ness” — that behavior comes entirely from which pair of commands you use consistently:

  • FIFO queue (first job in is the first job out): push with RPUSH (add to the tail) and pop with LPOP (remove from the head) — or the mirror image, LPUSH + RPOP. Either pairing works as long as you push and pop from opposite ends.
  • LIFO stack (most recent item in is the first item out): push and pop from the same end, e.g. LPUSH + LPOP, or RPUSH + RPOP.

For worker-style job queues, Redis also offers blocking popsBLPOP and BRPOP — which let a worker wait efficiently for a job to arrive instead of polling in a tight loop. A blocked client doesn’t consume CPU; when a push arrives on a key it’s watching, Redis wakes the longest-waiting blocked client for that key and delivers the popped element to it atomically, first-come-first-served.

The reliable queue problem

A naive queue worker does LPOP, processes the job, and moves on. But if the worker crashes after the pop and before finishing the work, that job is gone — it was already removed from the list and never went anywhere else. Redis solves this with LMOVE (and its blocking form, BLMOVE), which atomically pops an element from one list and pushes it onto another list in a single, indivisible step. A worker moves a job from a pending list to a processing list, does the work, and only then removes it from processing. If the worker dies mid-job, the item is still sitting safely in processing for a monitor process to recover.

Syntax

The general forms of the commands covered in this lesson:

LPUSH key value [value ...]
RPUSH key value [value ...]
LPOP key [count]
RPOP key [count]
LLEN key
LRANGE key start stop
LMOVE source destination LEFT|RIGHT LEFT|RIGHT
BLPOP key [key ...] timeout
BRPOP key [key ...] timeout
LTRIM key start stop
Command Purpose Time complexity
LPUSH key value [value ...] Push one or more values onto the head (left) of the list, creating it if needed O(1) per element pushed
RPUSH key value [value ...] Push one or more values onto the tail (right) of the list O(1) per element pushed
LPOP key [count] Remove and return element(s) from the head O(1), or O(N) with count
RPOP key [count] Remove and return element(s) from the tail O(1), or O(N) with count
LLEN key Return the number of elements in the list O(1)
LRANGE key start stop Return elements between two indexes (inclusive, 0-based; -1 is the last element) O(S+N)
LMOVE source dest LEFT|RIGHT LEFT|RIGHT Atomically pop from one list and push onto another O(1)
BLPOP key [key ...] timeout Blocking version of LPOP; waits up to timeout seconds (0 = wait forever) for an element O(1)
BRPOP key [key ...] timeout Blocking version of RPOP O(1)
LTRIM key start stop Trim the list so only elements in the given range remain O(N)

Examples

1. A stack: browser tab history (LIFO)

Every time the user opens a tab, push it onto the front of the list. “Back” pops from the same end, so the most recently opened tab is the first one you go back to.

LPUSH history:tabs "google.com"
LPUSH history:tabs "github.com"
LPUSH history:tabs "news.ycombinator.com"
LRANGE history:tabs 0 -1
LPOP history:tabs

Output:

(integer) 1
(integer) 2
(integer) 3
1) "news.ycombinator.com"
2) "github.com"
3) "google.com"
"news.ycombinator.com"

Each LPUSH returns the new list length. LRANGE shows the most recently pushed tab first, because LPUSH always inserts at the head. The final LPOP removes and returns that same most-recent tab — exactly LIFO order.

2. A queue: print jobs (FIFO)

Jobs are appended with RPUSH (added to the tail) and consumed with LPOP (removed from the head), so the job that has been waiting the longest is always processed first.

RPUSH queue:print-jobs "job:report.pdf"
RPUSH queue:print-jobs "job:invoice.pdf"
RPUSH queue:print-jobs "job:label.pdf"
LLEN queue:print-jobs
LPOP queue:print-jobs

Output:

(integer) 1
(integer) 2
(integer) 3
(integer) 3
"job:report.pdf"

report.pdf was pushed first and is popped first, even though invoice.pdf and label.pdf arrived later — that’s FIFO order. LLEN confirms the queue held 3 jobs right before the pop.

3. A reliable job queue with LMOVE and BLPOP

This is the realistic pattern: jobs sit in a pending list, and a worker atomically moves one into a processing list before starting work on it, so the job is never lost if the worker crashes mid-task.

RPUSH queue:emails:pending "email:welcome:1001"
RPUSH queue:emails:pending "email:receipt:1002"
LMOVE queue:emails:pending queue:emails:processing LEFT RIGHT
LRANGE queue:emails:pending 0 -1
LRANGE queue:emails:processing 0 -1
BLPOP queue:emails:pending 1

Output:

(integer) 1
(integer) 2
"email:welcome:1001"
1) "email:receipt:1002"
1) "email:welcome:1001"
1) "queue:emails:pending"
2) "email:receipt:1002"

LMOVE ... LEFT RIGHT atomically pops the oldest pending email (from the left, since it was the first pushed) and pushes it onto the right of processing — a worker would now safely process email:welcome:1001, knowing it’s recorded in processing even if the worker dies. The final BLPOP shows the blocking form: since queue:emails:pending still has an element, it returns immediately with a two-element array — the key name followed by the popped value — rather than blocking for the full timeout.

How it works step by step

Walking through what Redis does for RPUSH queue:jobs "job:1" followed by LPOP queue:jobs:

  • Redis looks up queue:jobs in the keyspace. If it doesn’t exist, a new empty list is created (backed by a listpack) before the push happens.
  • RPUSH appends "job:1" to the tail node of the list structure and returns the new length as an integer reply — this is O(1) because appending to the tail never requires touching earlier elements.
  • When the list is small, everything lives in one listpack node; once it crosses the configured size/length thresholds, Redis transparently splits it into a quicklist of multiple listpack nodes, still keeping head/tail operations O(1).
  • LPOP removes and returns the element at the head of the first node. If that node becomes empty, Redis frees it and moves on to the next node in the quicklist.
  • If the list becomes completely empty after a pop, Redis deletes the key entirely — an empty list is not a valid Redis value, so EXISTS queue:jobs would then return 0.
  • For BLPOP, if the list is empty when the command runs, Redis parks the client’s connection (without blocking other clients — Redis’s event loop keeps serving everyone else) and registers it against that key. The instant any client pushes to that key, Redis atomically pops the new element and delivers it to the longest-waiting blocked client, waking it up.

Common Mistakes

Mistake: mixing a list key with a non-list value

Every Redis key has exactly one type. Reusing a key that already holds a string for list operations fails immediately:

SET session:abc123 "active"
LPUSH session:abc123 "login-event"

Output:

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

The fix is to use a distinct, purpose-specific key for the list, e.g. session:abc123:events, rather than trying to store two different shapes of data under one key.

Mistake: popping a job before it’s actually done (no reliable-queue pattern)

A worker that does a plain LPOP and then processes the job has already removed it from Redis. If the worker process crashes between the pop and finishing the work, that job is permanently lost — there’s no record of it anywhere. Always use LMOVE (shown in Example 3) to atomically move the item into a processing list first, and only remove it from processing once the work is confirmed done. That way a monitoring process can detect and requeue anything left sitting in processing too long.

Mistake: thinking LRANGE removes elements

New users sometimes confuse the read-only LRANGE with the destructive pop commands. LRANGE never modifies the list — you can call it as many times as you like:

RPUSH queue:demo "a"
RPUSH queue:demo "b"
LRANGE queue:demo 0 -1
LRANGE queue:demo 0 -1
LLEN queue:demo

Output:

(integer) 1
(integer) 2
1) "a"
2) "b"
1) "a"
2) "b"
(integer) 2

Both calls to LRANGE return identical output and LLEN still reports 2 elements — nothing was consumed. Only LPOP/RPOP/LMOVE remove elements.

Mistake: letting a list grow forever

A list used as an activity log or timeline with no cap will keep consuming memory indefinitely. Use LTRIM to cap it to a fixed window after each push:

RPUSH log:events "event1"
RPUSH log:events "event2"
RPUSH log:events "event3"
LTRIM log:events -2 -1
LRANGE log:events 0 -1

Output:

(integer) 1
(integer) 2
(integer) 3
OK
1) "event2"
2) "event3"

LTRIM log:events -2 -1 keeps only the last two elements, discarding event1 — a cheap way to bound a list’s size instead of letting it grow without limit.

Best Practices

  • Pick one pairing per use case and stick to it: RPUSH+LPOP (or LPUSH+RPOP) for FIFO queues, LPUSH+LPOP (or RPUSH+RPOP) for LIFO stacks — mixing conventions across your codebase invites bugs.
  • Use BLPOP/BRPOP/BLMOVE for worker processes instead of polling with LPOP in a loop — blocking commands are cheaper on both the client and the server.
  • Use LMOVE (or BLMOVE for the blocking variant) instead of a separate pop-then-push for any “move to processing” step — it’s atomic, so there’s no window where a crash can lose the item.
  • Cap unbounded lists (logs, timelines, recent-activity feeds) with periodic LTRIM calls so memory usage stays predictable.
  • Avoid LRANGE key 0 -1 or repeated LINDEX calls on very large lists in hot code paths — both are O(N) and will slow down the single-threaded server under load.
  • For queueing needs beyond a simple single-consumer list — multiple independent consumer groups, message replay, delivery acknowledgment — reach for Redis Streams instead; Lists are the right tool for straightforward queues and stacks, not for advanced pub/sub-style fan-out.
  • Namespace queue-related keys clearly, e.g. queue:emails:pending and queue:emails:processing, so their role is obvious at a glance.
  • Monitor queue backlog with LLEN so you can alert before a queue grows large enough to indicate a stuck consumer.

Practice Exercises

  • Build an “undo history” stack under the key editor:undo: push three actions ("type-char", "delete-line", "paste"), then undo the two most recent actions. What single element should be left in the list, and which command do you check it with?
  • Build a FIFO print-job queue under queue:jobs with three jobs pushed in order. Use LLEN to confirm the size, then pop jobs one at a time until the queue is empty. Which end did you push to, and which end did you pop from to keep FIFO order?
  • Simulate a reliable worker: push two jobs onto queue:tasks:pending, then use LMOVE to move one into queue:tasks:processing. Confirm with LRANGE on both lists that the job moved rather than being duplicated or lost.

Summary

  • Redis Lists are ordered collections that support O(1) push and pop at either end, backed internally by a listpack (small lists) or quicklist (larger lists).
  • A FIFO queue pushes and pops from opposite ends (RPUSH + LPOP, or the mirror); a LIFO stack pushes and pops from the same end (LPUSH + LPOP, or the mirror).
  • BLPOP/BRPOP let a worker wait for new items efficiently instead of polling, and return a two-element array of key name plus value.
  • LMOVE atomically moves an element between two lists — this is the basis of the reliable queue pattern that survives a worker crashing mid-job.
  • List keys are strictly typed — mixing a list command with a key holding another type returns a WRONGTYPE error.
  • LRANGE and LLEN are read-only; only LPOP, RPOP, and LMOVE remove elements.
  • Use LTRIM to keep unbounded lists like logs and timelines from growing forever.
  • For advanced multi-consumer queueing, Redis Streams are a better fit than Lists.