Pattern Subscriptions
Verified on Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. Everything below is the output of the@runscripts and the replayed# cli/...transcript. Basics of Pub/Sub (at-most-once, RESP2 vs RESP3 subscribers) are in Pub/Sub.
Pattern subscriptions
Section titled “Pattern subscriptions”PSUBSCRIBE pattern [pattern ...] is SUBSCRIBE with glob patterns instead of literal channel names, so one subscription can follow a whole family of channels, including ones that do not exist yet.
| Pattern | Meaning |
|---|---|
* | any sequence of characters, including none |
? | exactly one character |
[abc], [a-c] | one character from the set or range |
[^x] | one character that is not x |
\* | a literal * |
I subscribed one connection to news.*, h?llo, log.[ew]*, raw.[^x] and published to eleven channels. Each row is the number of deliveries PUBLISH reported:
| Channel published | Deliveries | Why |
|---|---|---|
news.sports | 1 | matches news.* |
news. | 1 | * also matches the empty string |
news.a.b | 1 | . is an ordinary character, * spans it |
newsflash | 0 | no literal news. prefix |
hello, hallo | 1, 1 | ? is any single character |
heello | 0 | ? is exactly one |
log.error | 1 | [ew] = e or w |
log.info | 0 | i is not in the set |
raw.a, raw.x | 1, 0 | [^x] excludes x |
The pmessage event has four parts
Section titled “The pmessage event has four parts”A message that arrived through a pattern is not a message but a pmessage: ["pmessage", pattern, channel, payload]. In ioredis the two events have different handler signatures, and the same publish can fire both: I also subscribed to the literal channel news.tech and published there, which matches both the literal subscription and news.*. PUBLISH returned 2, and the client saw two events:
// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const sub = new Redis(url);const pub = new Redis(url);const seen: string[] = [];
// 'message' has (channel, message); 'pmessage' has FOUR arguments: (pattern, channel, message)sub.on('message', (channel, message) => seen.push(`message ${channel} ${message}`));sub.on('pmessage', (pattern, channel, message) => seen.push(`pmessage ${pattern} ${channel} ${message}`));
console.log(await sub.psubscribe('news.*', 'h?llo', 'log.[ew]*', 'raw.[^x]')); // 4 patterns -> count 4console.log(await sub.subscribe('news.tech')); // + 1 literal channel -> count 5
// which channels does each pattern catch? PUBLISH returns the number of deliveriesfor (const ch of ['news.sports', 'news.', 'news.a.b', 'newsflash', 'hello', 'hallo', 'heello', 'log.error', 'log.info', 'raw.a', 'raw.x']) { console.log(ch.padEnd(12), await pub.publish(ch, 'x'));}// news.tech matches BOTH the pattern news.* and the literal subscription: two deliveries, two eventsconsole.log('news.tech'.padEnd(12), await pub.publish('news.tech', 'both'));
await new Promise((r) => setTimeout(r, 50));console.log(seen.filter((s) => s.includes('news.tech'))); // [ 'message news.tech both', 'pmessage news.* news.tech both' ]// a match that came only through a pattern fires 'pmessage' and NOT 'message'console.log(seen.filter((s) => s.includes('news.sports'))); // [ 'pmessage news.* news.sports x' ]
sub.disconnect();pub.disconnect();Real output (this Mac):
45news.sports 1news. 1news.a.b 1newsflash 0hello 1hallo 1heello 0log.error 1log.info 0raw.a 1raw.x 0news.tech 2[ 'message news.tech both', 'pmessage news.* news.tech both' ][ 'pmessage news.* news.sports x' ]Two more facts from those numbers. The value returned by psubscribe/subscribe (4, then 5) is the total number of subscriptions on the connection, channels and patterns together. And a match that came only through a pattern fires pmessage and not message; a client that only listens for message sees nothing.
PUBLISH news.sports "score update"
PUBLISH news.tech "new release"
PUBLISH weather.today "sunny"
PUBSUB NUMPATPUNSUBSCRIBE
Section titled “PUNSUBSCRIBE”PUNSUBSCRIBE [pattern ...] removes pattern subscriptions, replying once per pattern with ["punsubscribe", pattern, remaining]. remaining counts all subscriptions left on the connection (channels and patterns), as the 4/5 above show, and the connection leaves subscribe mode when it reaches 0. With no arguments it removes every pattern.
What patterns cost
Section titled “What patterns cost”Every PUBLISH must be matched against every active pattern, whereas literal channels are a hash lookup. I measured the server-side average of 20,000 pipelined PUBLISH orders:1 x, from INFO commandstats, with subscribers whose patterns never match:
// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const pub = new Redis(url);
// average server time of PUBLISH over 20,000 publishes, from INFO commandstatsasync function usPerPublish(label: string) { await pub.config('RESETSTAT'); const batch = pub.pipeline(); for (let i = 0; i < 20000; i++) batch.publish('orders:1', 'x'); await batch.exec(); const line = (await pub.info('commandstats')).split('\r\n').find((l) => l.startsWith('cmdstat_publish')); console.log(label.padEnd(24), line?.match(/usec_per_call=([\d.]+)/)?.[1], 'us per PUBLISH');}
await usPerPublish('no subscriptions');for (const n of [100, 10000]) { const sub = new Redis(url); await sub.psubscribe(...Array.from({ length: n }, (_, i) => `p${i}:*`)); // none of them match orders:1 await usPerPublish(`${n} patterns`); sub.disconnect(); await new Promise((r) => setTimeout(r, 300));}const literal = new Redis(url);await literal.subscribe(...Array.from({ length: 10000 }, (_, i) => `c${i}`));await usPerPublish('10000 literal channels');literal.disconnect();pub.disconnect();Real output (this Mac):
no subscriptions 0.04 us per PUBLISH100 patterns 0.77 us per PUBLISH10000 patterns 115.57 us per PUBLISH10000 literal channels 0.09 us per PUBLISHSo 100 patterns cost about 20x the baseline (still under a microsecond), 10,000 patterns cost about 2,900x, roughly 0.1 ms of command thread for every publish, for every publisher; 10,000 literal channels cost nothing measurable. The cost is per pattern and paid on the publish path. Keep patterns few and narrow, and use literal channels when the names are known.
Keyspace notifications (teaser)
Section titled “Keyspace notifications (teaser)”Redis publishes internal events on built-in channels, which is what pattern subscriptions are most often used for: __keyevent@0__:expired (a key in db 0 expired) and __keyspace@0__:mykey (something happened to mykey). They are off by default. CONFIG GET reports the flags in a canonical order, so KEA comes back as AKE:
127.0.0.1:6379> CONFIG SET notify-keyspace-events KEAOK127.0.0.1:6379> CONFIG GET notify-keyspace-events1) "notify-keyspace-events"2) "AKE"127.0.0.1:6379> CONFIG SET notify-keyspace-events ""OKThe full lesson, with a timed expired event, is Keyspace notifications.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant writes a subscriber for all alert channels:
🐛 Spot the bug
const sub = new Redis(process.env.REDIS_URL!);await sub.psubscribe('alerts.*');sub.on('message', (channel, message) => {notifyOncall(channel, message); // never runs});Show the answer
The subscription succeeds and PUBLISH alerts.billing ... reports 1 delivery, but the handler is attached to message; a pattern subscription emits pmessage with an extra first argument, so nothing is ever notified. In our run the pattern-only match produced [ 'pmessage news.* news.sports x' ] and no message event at all. The fix is the right event and signature:
sub.on('pmessage', (pattern, channel, message) => { notifyOncall(channel, message);});Mental Model
Section titled “Mental Model”A pattern subscription is a filter the server evaluates for every message. It buys you not having to know channel names in advance, and you pay for it in CPU on every publish, on the one command thread. Treat patterns as a scarce resource, and treat pmessage as its own event with its own arguments.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
PSUBSCRIBE with a glob | One subscription for a whole family, including future channels | Matched against every publish: 0.04 us to 115 us per publish going from 0 to 10,000 patterns |
Literal SUBSCRIBE channels | Hash lookup, flat cost even at 10,000 channels (0.09 us) | You must list the names |
| Keyspace notifications over Pub/Sub | Real-time events with no polling | Extra work on commands that touch keys, still fire-and-forget |
| Also subscribing to a literal that a pattern covers | Sometimes deliberate | The same message is delivered twice (PUBLISH returned 2) |
Common Misconceptions
Section titled “Common Misconceptions”- “A pattern match arrives as a
message.” It arrives aspmessagewith four parts. - “The number returned by
PSUBSCRIBEcounts patterns.” It counts every subscription on the connection, channels included. - “Patterns are free.” They are linear in the number of active patterns: 115 us per publish at 10,000.
- “
*requires at least one character.”news.*matchednews..
Common mistakes
Section titled “Common mistakes”- Listening for
messageon a pattern subscription — attachpmessage(see the exercise). - Subscribing to
*or thousands of patterns — every publish pays for every pattern. - Enabling
KEA(everything) on a busy production server — enable only the event classes you consume.
Real-world example
Redis’s own documentation describes glob-style pattern subscriptions, the
pmessageformat and the subscription-count semantics in Redis Pub/Sub; the pattern rules are the ones of theKEYSglob (PSUBSCRIBE).