Redis TTL and EXPIRE: How Key Expiry Really Works, Tested
Sagar Tayde · · 8 min read
A Redis TTL (time to live) is the number of seconds a key has left before Redis deletes it on its own. Expiry is what keeps a cache fresh, logs a session out, resets a rate limit and stops a small store from filling up. This guide is for developers who set expiries and want to be sure they behave. It covers the commands, the return values, one common mistake that silently turns an expiring key into a permanent one, and what we measured about how quickly expired keys actually leave memory.
On this page
- The commands
- What TTL returns: -1 and -2
- The SET that deletes your expiry
- How expiry actually happens
- Tested: how fast expired keys free memory
- Expiring one field of a hash
- Expiry and a full store
- Four patterns worth copying
- FAQ
The commands
| Command | What it does |
|---|---|
SET key value EX 60 |
Set a value that expires in 60 seconds (PX for milliseconds) |
SET key value EXAT 1798761600 |
Expire at a Unix timestamp (PXAT for milliseconds) |
EXPIRE key 60 |
Add or change an expiry on an existing key |
EXPIRE key 60 NX |
Only if the key has no expiry yet (also XX, GT, LT; Redis 7 and later) |
TTL key / PTTL key |
Seconds / milliseconds left |
PERSIST key |
Remove the expiry; the key lives forever |
GETEX key EX 60 |
Read a value and reset its expiry in one step |
SET key value KEEPTTL |
Change the value but keep the current expiry |
HEXPIRE key 60 FIELDS 1 field |
Expire one field of a hash (Redis 7.4, Valkey 9) |
The full reference is in the EXPIRE documentation.
What TTL returns: -1 and -2
Setup for every test on this page: Redis 8.10.3 in a Linux virtual machine (WSL2) on a laptop, with
a Node.js 24 client using ioredis. Default configuration. Measured 7 November 2026.
| Key | TTL returned |
|---|---|
Set with EX 60 |
60 |
| Exists, no expiry | -1 |
| Does not exist (or has already expired) | -2 |
So -1 means “this key will never expire”, which is often a bug in a cache, and -2 means “it is
gone”. Check for both explicitly; treating any negative number as “expired” hides the -1 case.
The SET that deletes your expiry
This is a common cause of “my cache never expires” and “my store is full of old sessions”.
A plain SET on a key that already has an expiry removes the expiry.
| Step | TTL afterwards |
|---|---|
SET session v1 EX 3600 |
3600 |
SET session v2 (no options) |
-1, never expires |
SET session v3 EX 3600, then SET session v4 KEEPTTL |
3600 |
INCR counter, EXPIRE counter 60, then INCR counter |
60 (kept) |
Commands that change a value in place, such as INCR, APPEND, HSET or LPUSH, keep the expiry.
SET replaces the whole value and clears the expiry unless you pass EX, PX or KEEPTTL.
GETSET always clears it; use SET key value GET KEEPTTL instead. The safe habit: every SET on an expiring key carries its own EX, or KEEPTTL if you
mean to keep the old one.
// wrong: the session silently becomes permanent
await redis.set(`sess:${id}`, JSON.stringify(session));
// right: refresh the full lifetime, or keep the remaining one
await redis.set(`sess:${id}`, JSON.stringify(session), 'EX', 1800);
await redis.set(`sess:${id}`, JSON.stringify(session), 'KEEPTTL');
How expiry actually happens
Redis does not delete a key the instant its time is up. It uses two methods together:
- On access. When you read a key whose time has passed, Redis deletes it there and then and answers as if it never existed. You never get an expired value back.
- In the background. Several times a second, Redis samples keys that have an expiry and deletes the expired ones it finds. It keeps going while it keeps finding many, so a large batch of expired keys is cleared quickly.
The result is that expired keys are invisible to your app immediately, but can take a moment to leave
memory, and DBSIZE may briefly count keys that have already expired.
Tested: how fast expired keys free memory

All keys expiring together. We wrote 200,000 keys with 100-byte values and a 2-second expiry (36.9 MB), then watched.
| Time after loading finished | Keys counted by DBSIZE |
Memory above empty |
|---|---|---|
| 1.0 s | 161,861 | 28.9 MB |
| 2.5 s | 0 | 0.0 MB |
| 10 s | 0 | 0.0 MB |
Some keys had expired during the load itself. Within 2.5 seconds of the last write, every key and every byte was gone, without any reads. When most keys in a store are expired, the background cleanup finds them fast.
Expiring keys mixed with long-lived ones. We then wrote 200,000 keys where every second key expired after 2 seconds and the rest after an hour, so 100,000 should remain.
| Time | Keys counted by DBSIZE |
Expired keys still in memory |
|---|---|---|
| 3 s | 101,714 | 1,714 |
| 5 s | 101,607 | 1,607 |
| 10 s | 101,483 | 1,483 |
Here the cleanup slowed down: after 10 seconds about 1.5% of the expired keys were still in memory, because random samples now mostly hit keys that are not due. They are still invisible to your app, and a read deletes them on the spot. The practical lesson is small but real: on a nearly full store, do not count on expired keys freeing their memory instantly.
Expiring one field of a hash
Until recently an expiry applied to a whole key. Redis 7.4 added field-level expiry for hashes, and Valkey 9.0 added the same commands.
HSET user:1 name Asha otp 482913
HEXPIRE user:1 2 FIELDS 1 otp -> 1 (expiry set)
# 2.5 seconds later
HGETALL user:1 -> name: Asha (otp is gone)
We tested exactly that: after 2.5 seconds the otp field had disappeared and name remained. It is a
neat fit for one-time codes, per-device tokens or feature flags stored on a user record. Check that your
host’s engine version supports it before you rely on it.
Expiry and a full store
Expiry and eviction are different things. Expiry deletes keys whose time is up. Eviction deletes keys early because memory is full, and only if the store is configured to do so.
- With a no-eviction setting, a full store refuses new writes. Expiries are then your main way of making room, so every cache key should have one.
- The volatile eviction policies (
volatile-lru,volatile-ttland others) only ever evict keys that have an expiry. If none of your keys have one, those policies behave like no-eviction. - The allkeys policies evict any key, expiry or not.
On Voroa, a key value store’s When memory is full setting defaults to Keep everything, which refuses writes when full, and you can switch it to a drop option for a pure cache. On the 128 MB free size, consistent expiries are what keep a cache from filling up. The setting is explained in the Key Value guide.
Four patterns worth copying
Sliding session expiry. Reset the lifetime on every request, in one command:
const session = await redis.getex(`sess:${id}`, 'EX', 1800); // read and extend to 30 min
Fixed-window rate limit. Count requests and start the window only on the first one:
// Redis 7+: count and set the window in one atomic transaction
const [[, n]] = await redis.multi()
.incr(`rl:${ip}`)
.expire(`rl:${ip}`, 60, 'NX') // only starts the window; never extends it
.exec();
if (n > 100) return res.status(429).end();
On older versions you will see INCR followed by EXPIRE only when the count is 1. That works until
the process dies between the two calls: the counter then never expires, and that user is blocked for
good. On a version before 7, do the increment and the first expiry together in a short Lua script
instead.
Cache with jitter. Add a few random seconds so thousands of keys written together do not all expire in the same second and send every request to your database at once:
const ttl = 300 + Math.floor(Math.random() * 60);
await redis.set(`cache:${key}`, value, 'EX', ttl);
One-time codes. Store the code with a short expiry and delete it on use:
await redis.set(`otp:${phone}`, code, 'EX', 300);
// on verify
const ok = (await redis.getdel(`otp:${phone}`)) === submitted;
If you run cleanup work on a timer instead, such as a nightly purge of old records in your database, a scheduled task is the right tool; let Redis handle expiry for its own keys. Jobs that must run later are a job queue’s delayed jobs, covered in our Redis job queue guide.
Run it next to your app
Voroa runs a Redis-compatible key value store in Mumbai beside the web services you deploy, with a free size to start; the connection URL goes in an environment variable. See the sizes on the managed Redis page.
FAQ
What does TTL -1 mean in Redis?
The key exists and has no expiry, so it will never be deleted on its own. A plain SET on an expiring
key is a common cause.
What does TTL -2 mean? The key does not exist, either because it was never set or because it has expired.
Does SET remove the TTL?
Yes. SET without EX, PX or KEEPTTL clears any existing expiry. INCR, HSET, LPUSH and
similar in-place changes keep it.
Are expired keys deleted immediately? They become invisible immediately and are deleted when accessed or by background cleanup. In our test, 200,000 keys expiring together were gone from memory within 2.5 seconds; when mixed with long-lived keys, about 1.5% lingered after 10 seconds.
Can I set a TTL on one field of a hash?
Yes, with HEXPIRE on Redis 7.4 and later or Valkey 9.0 and later.
What is the maximum TTL? Expiry times are stored as millisecond Unix timestamps, so in practice any lifetime you need works. For “forever”, do not set an expiry at all.
Tested 7 November 2026 with Redis 8.10.3 in WSL2 on a laptop. Found something out of date? Write to support@getvoroa.com.