Redis Keyspace Notifications: Cheap Expiry Hooks With Sharp Edges
Redis can fire events when keys expire. It sounds like a free lightweight queue. Sometimes it is. Sometimes it silently drops jobs and you never know.
Redis keyspace notifications let you subscribe to events — key set, key deleted, key expired — directly on the Redis connection. No separate queue infrastructure, no polling loop, no Horizon workers burning memory waiting for jobs that arrive once a minute. For certain expiry-driven workflows, that's genuinely attractive. But there's a catch that Redis buries in the docs and that cost me a debugging session I'd rather not repeat.
What Problem This Actually Solves
Let's say you have a reservation system. A user starts a checkout, you hold inventory for 15 minutes, and if they abandon it you need to release that inventory back to the pool. The naive approach is a scheduled job that runs every minute, scans a table, finds expired reservations, and cleans them up. That works fine, but it's polling — you're hitting the database on a cadence regardless of whether anything needs doing.
The slightly less naive approach is a proper job queue: when the reservation is created, dispatch a ReleaseInventory job with a 15-minute delay. That works well too, but now you need a queue worker, you need Horizon or Supervisor watching it, and you need the queue backend to be reliable.
Redis keyspace notifications offer a third path: set a key with a TTL, subscribe to expiry events, and react when Redis fires the notification. The inventory hold creates a key like reservation:hold:{id} with a 900-second TTL. When it expires, Redis broadcasts an event. Your subscriber catches it and releases the inventory.
No polling. No separate queue. Just Redis, which you probably already have.
Enabling It
Keyspace notifications are off by default because they have a CPU cost. You enable them by setting notify-keyspace-events in your Redis config or at runtime:
redis-cli config set notify-keyspace-events Ex
E means keyspace events (as opposed to keyevent events — yes, Redis distinguishes these). x means expired events specifically. You can also use K for keyspace channel format instead of E for keyevent. I'll explain the channel naming in a second because it trips people up.
To verify it's set:
redis-cli config get notify-keyspace-events
The Two Channel Formats
Redis publishes expiry events on two types of channels:
- Keyspace:
__keyspace@{db}__:{key}— tells you what happened to a specific key - Keyevent:
__keyevent@{db}__:{event}— tells you which keys had a specific event happen
For expiry workflows I almost always want keyevent format. I'm listening for all expirations, not watching one specific key. So I subscribe to:
__keyevent@0__:expired
The 0 is the database index. If you're using REDIS_DB=1 in your Laravel config, adjust accordingly. I've been burned by this — app uses DB 1, I subscribe to DB 0, events fire into the void.
A Working Laravel Subscriber
Here's how I'd wire this up in Laravel. I'd put the subscriber in a long-running console command, managed by Supervisor.
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
use App\Jobs\ReleaseInventoryHold;
class ListenForReservationExpiry extends Command
{
protected $signature = 'reservations:listen-expiry';
protected $description = 'Subscribe to Redis keyspace expiry events for reservation holds';
public function handle(): void
{
$db = config('database.redis.default.database', 0);
$channel = "__keyevent@{$db}__:expired";
$prefix = 'reservation:hold:';
$this->info("Subscribing to {$channel}");
Redis::connection('default')->subscribe(
[$channel],
function (string $message, string $channel) use ($prefix) {
// $message is the key that expired
if (!str_starts_with($message, $prefix)) {
return; // lots of other keys expire; filter aggressively
}
$reservationId = substr($message, strlen($prefix));
$this->line("Expiry caught for reservation: {$reservationId}");
// Dispatch to a real queue worker so this subscriber
// doesn't block on the actual work
ReleaseInventoryHold::dispatch($reservationId);
}
);
}
}
A few things to notice:
- Filter immediately. Every key that expires on that database fires this callback. If you have a cache-heavy app, that's a lot of noise. Check the prefix and bail fast.
- Dispatch a job for the real work. Don't do database writes inside the subscribe callback. The subscribe loop is blocking — if your callback takes 500ms, you're missing events during that window. Treat the notification as a trigger, do the real work in a queued job.
- The
subscribecall blocks. This command runs forever. Supervisor keeps it alive.
Supervisor config looks like this:
[program:reservation-expiry-listener]
command=php /var/www/artisan reservations:listen-expiry
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/reservation-expiry.log
stderr_logfile=/var/log/supervisor/reservation-expiry-err.log
user=www-data
The Gotchas That Will Bite You
Notifications are not guaranteed
This is the one. Redis documents it clearly and people skip right past it:
Because of the way Redis expires keys, expired events may arrive with some delay relative to when the key actually expired. When a key expires, Redis may not immediately send the notification if no command has accessed the key.
Redis has two expiration strategies: lazy (expire on access) and active (periodic sweep). If a key expires but nothing touches it and the active sweep hasn't run yet, the notification fires late — or not at all before a restart.
In practice this means: if your Redis instance crashes or restarts, in-flight expiry notifications are gone. There's no persistence for pub/sub events. No replay. No dead-letter queue. They evaporate.
For reservation holds, that means a crash at the wrong moment leaves inventory locked. For a low-stakes use case — clearing a session flag, triggering a cache warm — losing an occasional event is fine. For anything financial or inventory-critical, this is disqualifying without a compensating reconciliation job.
You don't get the key's value, only its name
By the time the expiry notification fires, the key is gone. Redis tells you the key name. If you need the data that was stored in the key, you can't retrieve it — it's expired.
Design around this: encode everything you need in the key name itself (e.g., reservation:hold:{reservation_id}:{user_id}), or store the canonical data in your database and use the key name only as a lookup token.
One subscriber per notification, unless you coordinate
Pub/sub in Redis is fan-out: every subscriber on that channel gets every message. If you run two instances of your listener (e.g., two app servers both running the Supervisor process), both will catch the expiry event and both will dispatch the job. Your job handler needs to be idempotent, and you probably want a database-level lock or a where clause with a status check before doing work.
This isn't hypothetical. I deploy to two app servers by default. I learned this the hard way watching inventory get double-released in staging.
The notify-keyspace-events setting doesn't survive a config reload
If your Redis is managed (ElastiCache, Redis Cloud, Upstash), check whether notify-keyspace-events persists across restarts or whether you need to set it in the provider's config panel. Setting it via config set at runtime works but doesn't survive a restart unless it's in redis.conf. Upstash in particular has it as a console toggle. ElastiCache has a parameter group setting. If you're relying on config set in a deploy script, audit that.
When I'd Reach for This
Good fits:
- Lightweight session or token cleanup where losing an occasional event is harmless
- Triggering a cache warm or invalidation when a short-lived key expires
- Internal tooling where eventual consistency is acceptable
- Workflows where you have a compensating reconciliation job that catches misses
Not the right tool:
- Anything where every event must be processed exactly once (use a proper queue)
- Financial or inventory state transitions without a reconciliation safety net
- High-volume expiry events where the pub/sub fan-out creates its own load problems
- Multi-tenant Redis clusters where you don't control the
notify-keyspace-eventssetting
For most of my client work — healthcare integrations, e-commerce inventory, biotech LIMS workflows — I end up reaching for a Redis queue with Horizon anyway. The "I don't need queue infrastructure" pitch falls apart once you account for the missed-event risk and the coordination logic you have to write yourself.
But I used keyspace notifications last year for a print management client to trigger PDF generation when a staging key expired. Low stakes, best-effort, and the job was idempotent anyway. It's been running for 14 months without a hiccup. Right tool, right job.
The Bottom Line
Redis keyspace notifications are a legitimate tool, not a toy. But the gap between "this works in development" and "this is safe in production" is the part where events silently disappear. Know that going in, design for it, and you'll be fine. Treat it as a guaranteed queue and eventually it'll cost you a frantic Saturday morning.
Need help shipping something like this? Get in touch.