Fix: Redis Key Not Expiring (TTL / EXPIRE Not Working)
Quick Answer
How to fix Redis keys that never expire — why EXPIRE commands are silently overwritten, how SET options replace TTL, how to inspect key TTL, and how to prevent accidental TTL removal in your application.
The Error
You set an expiration on a Redis key, but the key never disappears:
SET session:abc123 '{"userId":1}'
EXPIRE session:abc123 3600 # Set 1-hour TTL
# One hour later...
TTL session:abc123
# (integer) -1 ← key exists but has no expiration!Or keys that should expire accumulate and Redis memory grows unbounded. Or in application code:
await redis.set('cache:users', JSON.stringify(users));
await redis.expire('cache:users', 300); // 5 minutes
// Later...
await redis.ttl('cache:users'); // Returns -1 — no TTL setWhy This Happens
Redis TTL (time-to-live) is a property of a key. The expiration is removed whenever a key is overwritten by certain commands. The most common causes:
- SET overwrites the TTL —
SET key valuereplaces the key entirely, including removing any existing TTL. If your code callsSETagain afterEXPIRE, the expiration is gone. - Two-step SET + EXPIRE is not atomic — between
SETandEXPIRE, another process may callSETon the same key, resetting the TTL before yourEXPIREruns. - Wrong EXPIRE syntax —
EXPIRE key secondsrequires an integer. Passing a float or string is silently ignored in some Redis versions. - Persistence configuration (RDB/AOF) — a Redis restart after loading a snapshot can restore keys without their TTL if the snapshot was taken at the wrong moment.
- PERSIST was called — the
PERSISTcommand explicitly removes a key’s expiration. A bug in application code may call it unintentionally. - Redis version differences — behavior of
SETwithEX/PXoptions changed across versions; using an old client library with a newer Redis can cause silent mismatches.
Fix 1: Use SET with EX Option Instead of Two Commands
The atomic way to set a value and its TTL in a single command:
# Redis CLI
SET session:abc123 '{"userId":1}' EX 3600 # Expires in 3600 seconds
SET session:abc123 '{"userId":1}' PX 3600000 # Expires in 3600000 milliseconds
SET session:abc123 '{"userId":1}' EXAT 1735689600 # Expires at Unix timestampNode.js (ioredis):
// Two-step — NOT atomic, TTL can be lost if SET is called again
await redis.set('cache:users', JSON.stringify(users));
await redis.expire('cache:users', 300); // TTL may be lost
// Single command — atomic, always sets TTL with the value
await redis.set('cache:users', JSON.stringify(users), 'EX', 300);
// or with ioredis options object:
await redis.set('cache:users', JSON.stringify(users), { EX: 300 });Node.js (node-redis):
await client.set('cache:users', JSON.stringify(users), { EX: 300 });Python (redis-py):
# Two-step — not atomic
r.set('cache:users', json.dumps(users))
r.expire('cache:users', 300)
# Single command — atomic
r.set('cache:users', json.dumps(users), ex=300)
r.setex('cache:users', 300, json.dumps(users)) # EquivalentGo (go-redis):
err := rdb.Set(ctx, "cache:users", data, 300*time.Second).Err()Pro Tip: Always use
SET key value EX secondsas the default pattern for any cached data. Never separate the value-set and TTL-set into two commands unless you specifically need to update only the TTL.
Fix 2: Preserve TTL When Updating a Value
If you need to update a key’s value without changing its existing TTL, use KEEPTTL:
# Redis 6.0+
SET session:abc123 '{"userId":1,"updated":true}' KEEPTTL
# Updates the value but keeps the existing expiration timeNode.js:
// Update value, keep existing TTL (Redis 6.0+)
await redis.set('session:abc123', JSON.stringify(newData), 'KEEPTTL');
// Check current TTL before deciding what to do
const ttl = await redis.ttl('session:abc123');
if (ttl > 0) {
// Key exists and has TTL — update value, keep TTL
await redis.set('session:abc123', JSON.stringify(newData), 'KEEPTTL');
} else if (ttl === -1) {
// Key exists but has no TTL — set value with new TTL
await redis.set('session:abc123', JSON.stringify(newData), 'EX', 3600);
} else {
// Key does not exist (ttl === -2) — set with TTL
await redis.set('session:abc123', JSON.stringify(newData), 'EX', 3600);
}Use GETSET or GET option to update atomically:
# Redis 6.2+ — GET returns old value, sets new value, preserves no TTL
SET session:abc123 '{"userId":1}' EX 3600 GET # Returns old value
# For true update-with-keepttl — use a Lua script for atomicity
EVAL "
local ttl = redis.call('TTL', KEYS[1])
if ttl > 0 then
return redis.call('SET', KEYS[1], ARGV[1], 'EX', ttl)
else
return redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
end
" 1 session:abc123 '{"userId":1}' 3600Fix 3: Inspect Key TTL to Diagnose the Problem
Before fixing, confirm what is actually happening with your keys:
# Check TTL of a key
TTL session:abc123
# -2 = key does not exist
# -1 = key exists, no expiration set
# ≥0 = remaining seconds until expiration
# Check in milliseconds
PTTL session:abc123
# Check when the key was set (Redis 7.0+)
OBJECT ENCODING session:abc123
OBJECT FREQ session:abc123
# Inspect key details (Redis 7.4+)
OBJECT HELPMonitor SET commands targeting a specific key:
# Redis CLI — watch all commands in real time
redis-cli MONITOR | grep "session:abc123"
# Output — look for SET commands that follow your EXPIRE:
# 1710000001.123 [0 127.0.0.1:54321] "SET" "session:abc123" "{...}" ← overwrites TTL
# 1710000001.456 [0 127.0.0.1:54321] "EXPIRE" "session:abc123" "3600"
# 1710000005.789 [0 127.0.0.1:54321] "SET" "session:abc123" "{...}" ← TTL gone againFix 4: Use SETNX / SET NX to Avoid Overwriting Existing Keys
If multiple processes might write the same key, use NX (only set if not exists) to prevent overwriting:
# Only set if key does not exist — preserves existing TTL on existing keys
SET session:abc123 '{"userId":1}' EX 3600 NX
# Returns OK if key was set, (nil) if key already existedNode.js — cache-aside pattern:
async function getCachedUsers() {
const cached = await redis.get('cache:users');
if (cached) return JSON.parse(cached);
const users = await db.query('SELECT * FROM users');
// NX prevents cache stampede — only the first request sets the value
await redis.set('cache:users', JSON.stringify(users), 'EX', 300, 'NX');
return users;
}XX — only update if key exists (preserves TTL-less keys):
# Only update value if key already exists (key was previously SET with EX)
SET session:abc123 '{"refreshed":true}' XX KEEPTTLFix 5: Fix Frameworks That Silently Reset TTL
Some caching libraries and ORMs call SET without TTL when updating cached objects. Check your library’s behavior:
Express-session with connect-redis:
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const redisClient = createClient();
const store = new RedisStore({
client: redisClient,
ttl: 86400, // 24 hours — sets EX on every session write
disableTouch: false, // true = do not reset TTL on each request (be careful)
});
app.use(session({
store,
resave: false, // false = do not call store.set() if session unchanged
saveUninitialized: false,
cookie: { maxAge: 86400000 },
}));Warning:
resave: truecausesstore.set()to be called on every request, even if the session did not change. If your store implementation callsSET key valuewithout EX, this removes the TTL on every request. Useresave: falseunless you have a specific reason fortrue.
Bull / BullMQ job queues:
BullMQ stores job data in Redis. Completed jobs are retained until they expire — configure retention:
const queue = new Queue('emails', {
defaultJobOptions: {
removeOnComplete: { age: 3600, count: 1000 }, // Remove after 1 hour or 1000 jobs
removeOnFail: { age: 24 * 3600 }, // Remove failed jobs after 24 hours
},
});Fix 6: Set a maxmemory-policy to Handle Key Expiry Under Memory Pressure
If Redis runs out of memory, it may evict keys or stop accepting writes — but expired keys may not be collected fast enough:
# Check current memory policy
redis-cli CONFIG GET maxmemory-policy
# Set a policy that evicts expired keys first
redis-cli CONFIG SET maxmemory-policy allkeys-lru
# Common policies:
# volatile-lru — evict keys with TTL set, least recently used
# allkeys-lru — evict any key, least recently used
# volatile-ttl — evict keys with TTL set, closest to expiry first
# noeviction — return error when memory is full (default)Check how many expired keys are waiting for lazy expiration:
redis-cli INFO stats | grep expired_keys
# expired_keys:12345 — number of keys expired since start
redis-cli INFO keyspace
# db0:keys=50000,expires=12000,avg_ttl=150000
# expires = keys with TTL setRedis uses lazy expiration (checks TTL when a key is accessed) plus periodic active expiration (samples random keys). In high-load scenarios, expired keys may persist slightly longer than their TTL — this is expected behavior.
Still Not Working?
Check if your Redis client library silently calls PERSIST. Some libraries have a “refresh” method that calls PERSIST to remove TTL when updating a cached item. Search your codebase:
grep -r "persist\|PERSIST" src/ --include="*.js" --include="*.ts"Verify Redis version compatibility. SET key value EX seconds KEEPTTL requires Redis 6.0+. Check your version:
redis-cli INFO server | grep redis_versionTest with DEBUG SLEEP to observe expiration:
redis-cli SET testkey hello EX 5
redis-cli TTL testkey # Returns ~5
redis-cli DEBUG SLEEP 6 # Sleep 6 seconds (blocks Redis — dev only)
redis-cli EXISTS testkey # Returns 0 — key expiredCheck for Redis Cluster partitioning. In a Redis Cluster, a key always lives on a specific shard. If your application connects to multiple nodes and writes to one while reading from another, you may be seeing a different key (or no key) than expected.
For related Redis issues, see Fix: Redis Connection Refused and Fix: Redis OOM Command Not Allowed.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Redis OOM command not allowed when used memory > maxmemory
How to fix Redis OOM command not allowed when used memory exceeds maxmemory caused by memory limits, missing eviction policies, large keys, and memory fragmentation.
Fix: WRONGTYPE Operation against a key holding the wrong kind of value (Redis)
How to fix Redis errors: WRONGTYPE Operation against a key holding the wrong kind of value, MISCONF Redis is configured to save RDB snapshots, OOM command not allowed, READONLY You can't write against a read only replica, and other common Redis errors. Covers key type mismatches, disk issues, memory limits, eviction policies, connection problems, and serialization.
Fix: MongoDB "not primary" Write Error (Replica Set)
How to fix MongoDB 'not primary' errors when writing to a replica set — read preference misconfiguration, connecting to a secondary, replica set elections, and write concern settings.
Fix: Django Migration Conflict (Conflicting Migrations Cannot Be Applied)
How to fix Django migration conflicts — why multiple leaf migrations conflict, how to merge conflicting migrations, resolve dependency chains, and set up a team workflow to prevent migration conflicts.