skip to content

How does XREAD with the BLOCK option behave when tailing a Redis Stream, and what does passing the special ID $ mean?

level: seniorimportance: must knowfreq 45%

answer

  1. BLOCK only when nothing is available; nil on timeout, 0 = forever
  2. `$` resolved server-side at call time — first call only
  3. Loop cursor = last ID of the reply
  4. Blocked clients park; they don't hold the event loop
  5. Socket timeout must exceed BLOCK ms

basics

~20 s

BLOCK ms parks the connection until an entry newer than the given ID arrives or the timeout expires (BLOCK 0 waits forever); on timeout it returns nil. $ resolves to the stream's current last ID at call time, so you get only entries added afterwards — safe for the first call only, then you must pass the last ID you actually received.

solid answer

~60 s

`XREAD BLOCK <ms> STREAMS key <id>` returns entries **strictly newer** than `<id>`. If there are none, the client is suspended rather than getting an empty reply; Redis wakes it the moment a matching `XADD` happens, or returns **nil** when `<ms>` elapses. `BLOCK 0` waits indefinitely. `$` means **"the stream's last ID as of when this command is processed"**. It is resolved once, server-side, at call time. The critical consequence: **`$` is correct only for the very first call.** A loop that passes `$` every iteration misses everything appended between the reply and the next call — a classic silent data-loss bug. The correct loop stores the largest ID from each reply and passes that next time. Operationally: the blocked client occupies a connection but **does not occupy the server's execution thread** — blocking clients are parked and woken by the append, so thousands of them are fine. Because blocking is per-connection, a client that also issues other commands needs a separate connection. Note `BLOCK` is honoured only when *no* data is already available; if entries exist past your ID, the command returns immediately.

code

text · 11 lines
text
# WRONG — silently drops entries added while you were processing
loop:
  XREAD BLOCK 5000 STREAMS events $

# RIGHT — cursor carries forward
cursor = "$"                       # or a stored last ID, or 0 to replay
loop:
  reply = XREAD COUNT 100 BLOCK 5000 STREAMS events <cursor>
  if reply is nil: continue        # timeout, just loop
  process(reply)
  cursor = id of the last entry in reply

go deeper

for a junior

Know that BLOCK makes XREAD wait for new entries instead of returning empty, and that $ means only entries added from now on.

for a middle

Explain the cursor discipline — $ once, then the last received ID — plus nil-on-timeout, BLOCK 0, and reading multiple streams with one cursor each.

for a senior

Cover blocked clients not consuming the event loop, socket-timeout versus BLOCK interaction, dedicated connections, and how cursor-persistence placement decides at-least-once versus at-most-once.

for a principal

Position plain XREAD as unacknowledged fan-out with a client-owned cursor: state the delivery contract explicitly, size retention against worst-case consumer downtime, and be clear about what this design does and does not provide before choosing it.

## The shape of the command ``` XREAD [COUNT n] [BLOCK ms] STREAMS key [key ...] id [id ...] ``` All keys come first, then all IDs, positionally matched. The IDs are **exclusive**: you receive entries with an ID strictly greater than the one you passed, which is exactly right when the ID you pass is the last one you handled. ## What BLOCK actually does Without `BLOCK`, `XREAD` is a poll: if nothing is newer than your ID, you get a nil reply immediately. Polling in a tight loop wastes CPU and round trips and still adds latency equal to your poll interval. With `BLOCK ms`: - If matching entries **already exist**, they are returned immediately — `BLOCK` only applies when there is nothing to return. This is important: `BLOCK` never delays available data. - Otherwise the client is **parked**. Redis registers it as waiting on those keys and returns to serving other clients. It is not spinning and it is not holding the execution thread; the single-threaded event loop keeps running normally. - When an `XADD` appends to a watched key, Redis signals the key and the blocked client is served, typically at the end of the current command's execution. - If `ms` elapses first, the reply is **nil** (RESP2 null array). `BLOCK 0` means no timeout at all. Because the client is genuinely idle while parked, you must be careful with **client-side socket timeouts**: a driver configured with, say, a 3-second read timeout and `BLOCK 5000` will tear down the connection before Redis replies. The rule is to set the socket timeout comfortably above the BLOCK duration, or use a client whose stream API handles it (Lettuce, redis-py, go-redis all expose this). ## The `$` ID `$` is sugar for "whatever `last-generated-id` currently is". Redis substitutes it when it processes the command. Use it once, when a consumer starts and only cares about live traffic: ``` XREAD BLOCK 0 STREAMS events $ ``` Then never again. The failure mode of using it repeatedly: 1. `XREAD BLOCK 5000 STREAMS events $` blocks; entry `A` arrives; you get `A`. 2. You spend 40 ms processing `A`. During those 40 ms entries `B` and `C` are appended. 3. You call `XREAD BLOCK 5000 STREAMS events $` again. `$` now resolves past `C`. **`B` and `C` are never delivered to you** and nothing reports an error. The fix is a one-line discipline: take the **last ID of the last entry in the reply** and use it as the next call's ID. Other special IDs: `0` (or `0-0`) reads from the very beginning — the cold-start / resume-without-cursor case; `+` in `XREAD` (Redis 7.4+) means "the last entry currently in the stream", handy for peeking at the newest entry. ## Reading several streams at once ``` XREAD BLOCK 0 STREAMS orders payments 1699-4 1700-0 ``` One blocked call can wait on many streams; you are woken by whichever gets data first, and the reply tells you which stream each batch came from. Track one cursor **per stream**. This is much cheaper than one connection per stream, but note the reply may contain entries from only some streams, so your loop must update only the cursors it actually advanced. ## Guarantees and limits of plain XREAD Plain `XREAD` is a **fan-out** read: every consumer that reads with its own cursor sees every entry. There is no server-side position, no acknowledgement, no in-flight tracking, and no load balancing of entries across a set of workers. Those belong to a different mechanism in Redis Streams; do not attribute them to `XREAD`. So the delivery semantics are entirely determined by where you persist the cursor: - Persist **after** processing → at-least-once, duplicates after a crash. Almost always the right choice; make handlers idempotent. - Persist **before** processing → at-most-once, silent loss after a crash. And because the cursor lives in your process, an unclean restart of a consumer that only ever used `$` starts from "now" and skips everything accumulated while it was down. Store the cursor durably if that matters. ## Interaction with trimming A blocked reader that falls behind can have entries trimmed out from under it: `XTRIM`/`MAXLEN` deletes by position regardless of who has read what. The reader is not notified — it simply receives the next surviving entry. Retention must be sized against your worst-case consumer downtime. ## A correct loop, in words Start with a stored cursor if you have one, else `$` for live-only or `0` to replay. Call `XREAD COUNT n BLOCK 5000 STREAMS key cursor`. On nil, loop again (the timeout is also your liveness heartbeat — it lets the process notice shutdown signals). On entries, process them, set `cursor` to the last ID, persist it, and loop. Use a dedicated connection for the blocking call.

  • Does a client blocked in XREAD BLOCK 0 prevent Redis from serving other clients?
    No. Redis parks the blocked client and continues its event loop normally; the client is woken when an XADD signals the key it is waiting on. It costs a connection and a little memory, not execution time, so many blocked readers are fine. The commands that genuinely hurt the single thread are long O(N) operations, not blocked clients.
  • Your consumer processes each batch for 50 ms and calls XREAD with `$` each time. What do you expect to see, and how do you prove it?
    Entries appended during those 50 ms are never delivered, because `$` re-resolves to the stream's newest ID on every call. You can prove it by comparing the count of IDs the consumer logged against `XINFO STREAM key`'s `entries-added` (or by ranging over the ID gaps with XRANGE) — the consumer's set will have holes exactly where processing happened. The fix is to carry the last received ID forward as the cursor.
  • Why might a blocking XREAD appear to fail with a timeout error rather than returning nil?
    That is almost always the client's socket read timeout firing before the server's BLOCK interval expires — the driver gives up and closes or errors the connection. Configure the socket/command timeout above the BLOCK value, and give the blocking read its own connection so it does not stall unrelated commands sharing a pooled connection.

$ is like telling a librarian "notify me about books arriving from now on" — perfectly sensible once, but useless if you say it afresh every time you come back, because it forgets everything that arrived while you were reading.

saying these in an interview costs you the question

  • Passing `$` on every iteration of a tail loop instead of the last received ID.
  • Thinking a blocked client occupies the server's single execution thread.
  • Believing XREAD acknowledges entries or tracks a server-side position for the client.
  • Setting a client socket timeout shorter than the BLOCK duration and calling it a Redis bug.
  • Assuming BLOCK delays delivery when entries are already available past the given ID.

context