Redis Pub/Sub broadcasts to subscriptions that are active when a message is published. It does not retain an inbox for a disconnected listener. This suits replaceable live notifications, but a positive PUBLISH response is not evidence that a worker finished a job or that an offline consumer can recover it.

Subscription is a handshake

SUBSCRIBE produces a subscription confirmation before that connection starts receiving subsequent publications. Wait for this response when coordinating a publisher and a new listener. Sending the subscription request is not enough: another connection can publish before Redis has processed it. Reconnecting and subscribing again restores future delivery, not missing history.

Within the live connection, subscribers receive messages in publication order. That order reflects Redis processing, not business timestamps from independent producers. Dispatching received events to parallel handlers can also reorder completion. Saying Pub/Sub has no ordering is incorrect; saying ordered delivery guarantees ordered business effects is equally misleading.

Multiple subscribers each receive the broadcast, unlike workers competing for one list item. A channel subscription and a matching pattern subscription are separate delivery paths. Subscribing to events and event* can produce both a message and a pmessage for one publication. Overlapping patterns can do the same. At-most-once delivery describes the lack of retry, not application-level deduplication across subscriptions.

Interpret the response narrowly

On the tested standalone server, PUBLISH counts matching deliveries, including channel and pattern matches separately. Two client connections can therefore produce a return value of three. The pinned server implementation increments the counter along both paths. In Redis Cluster, the documented count includes only subscribers connected to the publishing node, so it is not a cluster-wide audience census.

No response waits for a handler to commit a database transaction, render an update, or acknowledge completion. A zero response means no matching recipients were counted at that moment; a positive response cannot prove business success. Put a stable event identifier in messages when the application must recognize duplicates caused by overlapping subscriptions or repeated producer attempts.

When it breaks

A subscription gap loses publications made during that gap. A reconnect loop cannot request an offset because Pub/Sub has no retained log or replay cursor. For cache invalidation, losing a notification can leave a stale value until another mechanism refreshes it. For billing or job dispatch, that gap can lose required work. Choose a retained queue or stream with explicit recovery and appropriate persistence when replay is required.

A slow subscriber is not immediately handled by arbitrarily dropping individual reads. Redis buffers outgoing data; configured hard or sustained soft output-buffer limits can close the connection. That prevents unbounded client buffering but creates an unrecoverable subscription gap. Observe connection churn and buffer usage, keep callbacks bounded, and do not treat increasing buffer limits as a delivery guarantee.

Check it quickly

Use a POSIX environment with a C compiler, make, curl, tar, and Python 3.10 or newer. This ran on macOS with Python 3.14.4, Redis 8.10.1, and redis-py 8.1.0. The commands build Redis core with libc and install the client in a temporary virtual environment, without optional modules or system-wide installation.

redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0

Run the Python block with "$redis_lab/venv/bin/python" in the same shell. It creates a private Unix socket under /tmp, disables TCP, scheduled snapshots, and append-only persistence, then terminates only its own process and removes its runtime directory. Downloads require network access. No default local Redis is contacted.

from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time

import redis
from redis.backoff import NoBackoff
from redis.retry import Retry


@contextmanager
def instance():
    with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
        endpoint = str(Path(directory) / "redis.sock")
        process = subprocess.Popen([
            os.environ["REDIS_SERVER"], "--port", "0",
            "--unixsocket", endpoint, "--unixsocketperm", "700",
            "--save", "", "--appendonly", "no", "--dir", directory,
            "--maxmemory-policy", "noeviction",
        ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
        client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
                             socket_timeout=2, retry=Retry(NoBackoff(), 0))
        try:
            deadline = time.monotonic() + 5
            while True:
                assert process.poll() is None, "Redis exited during startup"
                try:
                    if client.ping():
                        break
                except redis.ConnectionError:
                    if time.monotonic() >= deadline:
                        raise
                    time.sleep(0.02)
            yield client
        finally:
            client.close()
            if process.poll() is None:
                process.terminate()
            try:
                _, errors = process.communicate(timeout=5)
            except subprocess.TimeoutExpired:
                process.kill()
                process.communicate()
                raise
            assert process.returncode == 0, errors.decode()


def receive(subscription):
    deadline = time.monotonic() + 2
    while time.monotonic() < deadline:
        message = subscription.get_message(timeout=0.1)
        if message is not None:
            return message
    raise AssertionError("subscription response timed out")


with instance() as client:
    assert client.publish("events", b"before-subscribe") == 0
    with client.pubsub() as first, client.pubsub() as second:
        for subscription in (first, second):
            subscription.subscribe("events")
            response = receive(subscription)
            assert response["type"] == "subscribe"
            assert response["channel"] == b"events" and response["data"] == 1

        for value in (b"one", b"two"):
            assert client.publish("events", value) == 2
        for subscription in (first, second):
            messages = [receive(subscription) for _ in range(2)]
            assert [message["type"] for message in messages] == ["message"] * 2
            assert [message["data"] for message in messages] == [b"one", b"two"]
        print("before subscription: 0; two listeners receive one,two in order")

        second.unsubscribe("events")
        response = receive(second)
        assert response["type"] == "unsubscribe" and response["data"] == 0
        assert client.publish("events", b"missed") == 1
        assert receive(first)["data"] == b"missed"
        second.subscribe("events")
        assert receive(second)["type"] == "subscribe"
        assert client.publish("events", b"after-resubscribe") == 2
        for subscription in (first, second):
            assert receive(subscription)["data"] == b"after-resubscribe"
        print("resubscription receives the new marker, not the missed message")

        first.psubscribe("event*")
        response = receive(first)
        assert response["type"] == "psubscribe" and response["data"] == 2
        assert client.publish("events", b"overlap") == 3
        messages = [receive(first) for _ in range(2)]
        assert {message["type"] for message in messages} == {"message", "pmessage"}
        assert all(message["data"] == b"overlap" for message in messages)
        assert receive(second)["data"] == b"overlap"
        print("two clients, three matching deliveries; one client receives two copies")

The first publication returns zero before either listener subscribes. After both confirmations, two sequential publications reach both listeners as one, two. The example checks message types as well as payload order, so a subscription confirmation cannot accidentally count as a business event. It uses bounded response waits rather than guessing when subscriptions become active.

The second listener explicitly unsubscribes while missed is published, then resubscribes. Its next payload is after-resubscribe, not the missing event. This models a subscription gap without claiming to reproduce a network failure. Finally, overlapping channel and pattern subscriptions yield three counted deliveries across two connections, with two copies at the first listener.

All three printed summaries follow assertions against actual replies. The exercise verifies local command semantics and resubscription behavior, not overload thresholds, cluster propagation, crash recovery, or processing throughput. An application still needs to decide which notifications are expendable and how authoritative state will be refreshed when one never arrives.

Sources