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

In the pinned standalone implementation, 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.

Subscription confirmation, an unsubscribe gap and overlapping channel-pattern routes distinguish live ordering, missed history, three matches across two connections and business acknowledgement.
Live subscriptions have ordered delivery but no replay. Matching deliveries can overlap and do not acknowledge business completion. The unsubscribe fixture is unrun here.

Check it quickly

The article reports macOS, Python 3.14.4, Redis 8.10.1 and redis-py 8.1.0; no matching run log was located among reviewed evidence, and nothing was rerun here. Require POSIX, a C compiler, make, curl, tar and Python 3.10+. Keep assertions enabled: -O or PYTHONOPTIMIZE skips checks and required calls.

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 with "$redis_lab/venv/bin/python" in the same shell. Check setup succeeds; downloads need network. Redis uses a private /tmp Unix socket, with TCP, scheduled snapshots and AOF disabled. Cleanup attempts to stop its process and remove runtime files; the build/venv remains. Printed summaries precede cleanup. Default Redis is not 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