Skip to content

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.

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.

You can attach a TTL (time-to-live) when you create a key, or add one to an existing key:

CommandDescription
SET key val EX secsCreate + expire in seconds
SET key val PX msCreate + expire in milliseconds
EXPIRE key secsSet/update expiry in seconds on existing key
PEXPIRE key msSet/update expiry in milliseconds
EXPIREAT key unix-secsExpire at an absolute Unix timestamp (seconds)
PEXPIREAT key unix-msExpire at an absolute Unix timestamp (milliseconds)
cli/keys-strings-expiration-ttl-1.redis
127.0.0.1:6379> SET session:abc "user:42" EX 300
OK
127.0.0.1:6379> TTL session:abc
(integer) 300
127.0.0.1:6379> SET cache:home "<html>...</html>"
OK
127.0.0.1:6379> EXPIRE cache:home 60
(integer) 1
127.0.0.1:6379> TTL cache:home
(integer) 60
127.0.0.1:6379> EXPIRE nokey 60
(integer) 0

Read 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:home

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)
cli/keys-strings-expiration-ttl-2.redis
127.0.0.1:6379> SET persistent "I live forever"
OK
127.0.0.1:6379> TTL persistent
(integer) -1
127.0.0.1:6379> TTL nonexistent:key
(integer) -2
127.0.0.1:6379> SET brief "gone soon" PX 400
OK
127.0.0.1:6379> TTL brief
(integer) 0
127.0.0.1:6379> EXISTS brief
(integer) 1
127.0.0.1:6379> SET rounddown x PX 1400
OK
127.0.0.1:6379> TTL rounddown
(integer) 1
127.0.0.1:6379> SET roundup x PX 1600
OK
127.0.0.1:6379> TTL roundup
(integer) 2

TTL 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 brief

If you want to cancel a key’s expiry and make it persistent again, use PERSIST:

cli/keys-strings-expiration-ttl-3.redis
127.0.0.1:6379> SET promo "SALE10" EX 3600
OK
127.0.0.1:6379> TTL promo
(integer) 3600
127.0.0.1:6379> PERSIST promo
(integer) 1
127.0.0.1:6379> TTL promo
(integer) -1
127.0.0.1:6379> PERSIST promo
(integer) 0

PERSIST returns 1 only if there was a timeout to remove.

SET promo "SALE10" EX 3600
TTL promo
PERSIST promo
TTL promo

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):

FlagApplies the new TTL only if…
NXthe key has no TTL yet
XXthe key already has a TTL
GTthe new TTL is greater than the current one (a key with no TTL counts as infinite, so GT never applies there)
LTthe new TTL is less than the current one (no TTL counts as infinite, so LT applies)
cli/keys-strings-expiration-ttl-4.redis
127.0.0.1:6379> EXPIRE promo 100 NX
(integer) 1
127.0.0.1:6379> EXPIRE promo 200 NX
(integer) 0
127.0.0.1:6379> EXPIRE promo 50 GT
(integer) 0
127.0.0.1:6379> EXPIRE promo 300 GT
(integer) 1
127.0.0.1:6379> EXPIRE promo 400 LT
(integer) 0
127.0.0.1:6379> EXPIRE promo 10 LT
(integer) 1
127.0.0.1:6379> EXPIRE promo 10 XX
(integer) 1
127.0.0.1:6379> EXPIRE promo -1
(integer) 1
127.0.0.1:6379> EXISTS promo
(integer) 0

Step 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.

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:

Terminal window
127.0.0.1:6379> DEBUG SET-ACTIVE-EXPIRE 0
OK
127.0.0.1:6379> SET a 1 PX 100 # and b, c the same
127.0.0.1:6379> DBSIZE # 500 ms later
(integer) 3 # expired, but still in memory
127.0.0.1:6379> INFO keyspace
db0:keys=3,expires=3,avg_ttl=0,subexpiry=0
127.0.0.1:6379> GET a # lazy expiry: deleted on access
(nil)
127.0.0.1:6379> EXISTS b
(integer) 0
127.0.0.1:6379> DBSIZE
(integer) 1
127.0.0.1:6379> INFO stats # expired_keys:2 expired_keys_active:0
127.0.0.1:6379> DEBUG SET-ACTIVE-EXPIRE 1 # 500 ms later, key c is gone by itself
127.0.0.1:6379> DBSIZE
(integer) 0
127.0.0.1:6379> INFO stats # expired_keys:3 expired_keys_active:1

expired_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.

src/keys-strings-expiration-ttl-check.ts
// @run
import { 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 TTLs
const isGone = async (k: string) => (await redis.exists(k)) === 0;
console.log('isGone now:', await isGone(key)); // false
await 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 1
console.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.

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 regenerated
async 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.

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.

ChoiceBenefitCost
Set a TTL on every cache/session keyMemory is reclaimed automatically, no cleanup job to writeA 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 keyAn expired key nobody accesses keeps consuming memory until the active-expiry cycle finds it
Use PERSIST to cancel an existing TTLExtends the life of important data without creating a new keyEasy 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-writeNot on Redis < 7.0 or older managed services; older code needs WATCH or Lua
  • “TTL counts down by one from what I set, so EX 300 reads 299.” It reads 300 first and is rounded to the nearest second.
  • “TTL 0 means 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.
  • “EXPIRE extends a TTL.” It replaces it, possibly with a shorter one; use GT to only extend.
  • Checking TTL key == 0 to detect a missing key — a missing key is -2, and 0 means the key is alive with under half a second left. Branch on -2 (missing) and -1 (persistent), or use EXISTS.
  • Forgetting to set a TTL on a key that should be temporary — if you SET a cache or session value with no EX/PX, it becomes persistent and accumulates until maxmemory is full.
  • Confusing EXPIRE (relative) with EXPIREAT (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.

What does TTL return for a key that exists but has no expiration set?
Which command cancels an expiration and makes a key persistent?
What does PEXPIRE set compared to EXPIRE?
What does lazy expiry mean in Redis?
A key has PX 400 left. What does TTL key return?
A key has a 100 s TTL. What does EXPIRE key 50 GT do?