Expiration & TTL
Verified on Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. TTL values shown are what the server returned when the command ran; a lesson transcript read a second later would show one less.
Why expiration matters
Section titled “Why expiration matters”Redis is often used as a cache or a session store. In both cases you want keys to disappear automatically after some time — you should not have to write cleanup jobs. Redis handles this natively through key expiration.
Setting an expiration
Section titled “Setting an expiration”You can attach a TTL (time-to-live) when you create a key, or add one to an existing key:
| Command | Description |
|---|---|
SET key val EX secs | Create + expire in seconds |
SET key val PX ms | Create + expire in milliseconds |
EXPIRE key secs | Set/update expiry in seconds on existing key |
PEXPIRE key ms | Set/update expiry in milliseconds |
EXPIREAT key unix-secs | Expire at an absolute Unix timestamp (seconds) |
PEXPIREAT key unix-ms | Expire at an absolute Unix timestamp (milliseconds) |
127.0.0.1:6379> SET session:abc "user:42" EX 300OK127.0.0.1:6379> TTL session:abc(integer) 300127.0.0.1:6379> SET cache:home "<html>...</html>"OK127.0.0.1:6379> EXPIRE cache:home 60(integer) 1127.0.0.1:6379> TTL cache:home(integer) 60127.0.0.1:6379> EXPIRE nokey 60(integer) 0Read the real values: TTL right after EX 300 is 300, not 299 — a second or so later it would read 299. EXPIRE returns 1 if it set a timeout and 0 if the key does not exist.
SET session:abc "user:42" EX 300
TTL session:abc
SET cache:home "<html>...</html>"
EXPIRE cache:home 60
TTL cache:homeTTL and PTTL — checking remaining time
Section titled “TTL and PTTL — checking remaining time”TTL returns seconds; PTTL returns milliseconds. Both have two special return values:
-1— the key exists but has no expiry (it is persistent)-2— the key does not exist (or has already expired)
127.0.0.1:6379> SET persistent "I live forever"OK127.0.0.1:6379> TTL persistent(integer) -1127.0.0.1:6379> TTL nonexistent:key(integer) -2127.0.0.1:6379> SET brief "gone soon" PX 400OK127.0.0.1:6379> TTL brief(integer) 0127.0.0.1:6379> EXISTS brief(integer) 1127.0.0.1:6379> SET rounddown x PX 1400OK127.0.0.1:6379> TTL rounddown(integer) 1127.0.0.1:6379> SET roundup x PX 1600OK127.0.0.1:6379> TTL roundup(integer) 2TTL is rounded to the nearest second, not floored and not counted down by one. A key with 400 ms left reports TTL 0 while EXISTS still says 1. PX 1400 reads 1 and PX 1600 reads 2. Use PTTL whenever sub-second accuracy matters (PTTL brief read 278 a moment after the command above).
SET persistent "I live forever"
TTL persistent
TTL nonexistent:key
SET brief "gone soon" PX 5000
PTTL briefPERSIST — removing an expiration
Section titled “PERSIST — removing an expiration”If you want to cancel a key’s expiry and make it persistent again, use PERSIST:
127.0.0.1:6379> SET promo "SALE10" EX 3600OK127.0.0.1:6379> TTL promo(integer) 3600127.0.0.1:6379> PERSIST promo(integer) 1127.0.0.1:6379> TTL promo(integer) -1127.0.0.1:6379> PERSIST promo(integer) 0PERSIST returns 1 only if there was a timeout to remove.
SET promo "SALE10" EX 3600
TTL promo
PERSIST promo
TTL promoEXPIRE with NX, XX, GT, LT (Redis 7.0+)
Section titled “EXPIRE with NX, XX, GT, LT (Redis 7.0+)”Plain EXPIRE overwrites whatever timeout was there. Since 7.0 a flag makes the update conditional, which fixes several classic bugs (a “sliding window” that accidentally shortens a TTL, or a rate-limit counter whose window keeps restarting):
| Flag | Applies the new TTL only if… |
|---|---|
NX | the key has no TTL yet |
XX | the key already has a TTL |
GT | the new TTL is greater than the current one (a key with no TTL counts as infinite, so GT never applies there) |
LT | the new TTL is less than the current one (no TTL counts as infinite, so LT applies) |
127.0.0.1:6379> EXPIRE promo 100 NX(integer) 1127.0.0.1:6379> EXPIRE promo 200 NX(integer) 0127.0.0.1:6379> EXPIRE promo 50 GT(integer) 0127.0.0.1:6379> EXPIRE promo 300 GT(integer) 1127.0.0.1:6379> EXPIRE promo 400 LT(integer) 0127.0.0.1:6379> EXPIRE promo 10 LT(integer) 1127.0.0.1:6379> EXPIRE promo 10 XX(integer) 1127.0.0.1:6379> EXPIRE promo -1(integer) 1127.0.0.1:6379> EXISTS promo(integer) 0Step by step, from a persistent key: NX 100 works (it had no TTL), NX 200 is refused (now it has one), GT 50 is refused (50 < 100), GT 300 extends, LT 400 is refused, LT 10 shortens, XX 10 applies. The last two lines show a negative timeout: EXPIRE promo -1 returns 1 and the key is deleted immediately.
To read the deadline as an absolute time instead of a countdown use EXPIRETIME / PEXPIRETIME (7.0+): EXPIRETIME promo printed 1790747663 (Unix seconds — yours differs), -1 for a persistent key and -2 for a missing one.
How Redis actually removes expired keys
Section titled “How Redis actually removes expired keys”Redis uses two complementary strategies — you do not need to do anything, but understanding them helps you reason about memory:
Lazy expiry — when you access a key (GET, EXISTS, etc.), Redis checks its expiry first. If it has expired, Redis deletes it right then and returns (nil) as if it never existed.
Active expiry — Redis runs a background cycle (hz, default 10 times per second — CONFIG GET hz returns 10) that samples keys with TTLs and deletes the ones that have expired. This prevents the keyspace from filling up with keys that are never accessed.
You can watch both work. The debug command DEBUG SET-ACTIVE-EXPIRE 0 (needs --enable-debug-command yes, so run it only in a throwaway container) turns the active cycle off. Three keys with PX 100, then half a second of nobody touching them:
127.0.0.1:6379> DEBUG SET-ACTIVE-EXPIRE 0OK127.0.0.1:6379> SET a 1 PX 100 # and b, c the same127.0.0.1:6379> DBSIZE # 500 ms later(integer) 3 # expired, but still in memory127.0.0.1:6379> INFO keyspacedb0:keys=3,expires=3,avg_ttl=0,subexpiry=0127.0.0.1:6379> GET a # lazy expiry: deleted on access(nil)127.0.0.1:6379> EXISTS b(integer) 0127.0.0.1:6379> DBSIZE(integer) 1127.0.0.1:6379> INFO stats # expired_keys:2 expired_keys_active:0127.0.0.1:6379> DEBUG SET-ACTIVE-EXPIRE 1 # 500 ms later, key c is gone by itself127.0.0.1:6379> DBSIZE(integer) 0127.0.0.1:6379> INFO stats # expired_keys:3 expired_keys_active:1expired_keys counts every expiration; expired_keys_active (present in 8.10.2) counts the ones the background cycle did. Two were removed by the reads (lazy), one by the cycle. Together these strategies keep memory usage bounded without blocking your application. The active cycle stops early when it has used its CPU budget (INFO stats shows expired_time_cap_reached_count), so a huge burst of simultaneous expirations can leave dead keys in memory a little longer.
Checking expiry from Node.js
Section titled “Checking expiry from Node.js”// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const key = 'b1:tmp';
// the AI-written check: "TTL 0 means expired"const looksExpired = async (k: string) => (await redis.ttl(k)) === 0;
await redis.set(key, 'x', 'PX', 400);console.log('fresh key ttl:', await redis.ttl(key), 'looksExpired:', await looksExpired(key)); // 0 true (but it exists!)console.log('missing key ttl:', await redis.ttl('b1:missing'), 'looksExpired:', await looksExpired('b1:missing')); // -2 false
// the fix: ask about existence, and use the millisecond clock for short TTLsconst isGone = async (k: string) => (await redis.exists(k)) === 0;console.log('isGone now:', await isGone(key)); // falseawait new Promise((r) => setTimeout(r, 600));console.log('isGone 600 ms later:', await isGone(key)); // true
// EXPIRE flags: extend only (GT), set only if no TTL yet (NX)await redis.set(key, 'x', 'EX', 100);console.log(await redis.expire(key, 50, 'GT'), await redis.expire(key, 200, 'GT')); // 0 1console.log(await redis.expire(key, 10, 'NX')); // 0
await redis.del(key);await redis.quit();Real output: fresh key ttl: 0 looksExpired: true, missing key ttl: -2 looksExpired: false, isGone now: false, isGone 600 ms later: true, then 0 1 and 0. The predicate is wrong in both directions: it reports a live key as expired and a missing key as fine.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant writes a helper that decides whether a cached entry needs rebuilding:
🐛 Spot the bug
// returns true when the cached report has expired and must be regeneratedasync function needsRebuild(key: string): Promise<boolean> {const ttl = await redis.ttl(key);return ttl === 0;}Show the answer
TTL never returns 0 for a key that is gone — it returns -2. And it does return 0 while a key is still alive with under half a second left. So needsRebuild is false for every missing key (the report is never rebuilt after it expires) and true for a key that is about to be served for another few hundred milliseconds. We ran it against a PX 400 key and a missing key: looksExpired came back true and false respectively, the exact opposite of the intent. The fix is EXISTS (or TTL === -2, or PTTL), as in the isGone helper above.
Mental Model
Section titled “Mental Model”A TTL is a deadline stamped on the key, not a timer the key runs. Redis stores an absolute expiry time; TTL merely subtracts “now” and rounds. The deadline is enforced two ways: whoever touches the key after the deadline finds it gone (lazy), and a background sweeper visits keys that have deadlines (active). Anything that rewrites the key (SET) throws the deadline away unless you say KEEPTTL.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Set a TTL on every cache/session key | Memory is reclaimed automatically, no cleanup job to write | A miscalculated TTL can expire data too early while the system still needs it |
| Rely on lazy expiry (checked on access) | No extra overhead if nobody ever reads that key | An expired key nobody accesses keeps consuming memory until the active-expiry cycle finds it |
Use PERSIST to cancel an existing TTL | Extends the life of important data without creating a new key | Easy to forget the key was meant to be temporary, letting memory grow unchecked |
EXPIRE ... NX/GT/LT (7.0+) | Conditional update in one atomic command, no read-modify-write | Not on Redis < 7.0 or older managed services; older code needs WATCH or Lua |
Common Misconceptions
Section titled “Common Misconceptions”- “
TTLcounts down by one from what I set, soEX 300reads 299.” It reads300first and is rounded to the nearest second. - “
TTL 0means the key is gone.” It means under half a second remains; the key exists. Gone is-2. - “An expired key disappears at exactly its deadline.” Reads never see it after the deadline, but the memory is freed lazily or by the sweeper.
- “
EXPIREextends a TTL.” It replaces it, possibly with a shorter one; useGTto only extend.
Common mistakes
Section titled “Common mistakes”- Checking
TTL key == 0to detect a missing key — a missing key is-2, and0means the key is alive with under half a second left. Branch on-2(missing) and-1(persistent), or useEXISTS. - Forgetting to set a TTL on a key that should be temporary — if you
SETa cache or session value with noEX/PX, it becomes persistent and accumulates untilmaxmemoryis full. - Confusing
EXPIRE(relative) withEXPIREAT(absolute timestamp) — using the wrong one shifts the TTL away from what you intended, especially when the client and server clocks disagree.
Real-world example
Stack Overflow ran an L1/L2 cache (local memory plus Redis) in front of SQL Server at a reported scale of roughly 160 billion Redis operations a month (Stack Overflow: The Architecture – 2016 Edition, Nick Craver, 2016 — a ten-year-old figure). Expiring cached values with a TTL, as shown in this lesson, is exactly the mechanism that keeps that kind of cache from serving stale data forever.