First, is it really Redis?
A stale page can come from a browser cache, a content delivery network, an application memo, a read replica that lags or Redis. Before changing anything, delete the one cache key for the affected object on a test copy and reload. If the new value appears, Redis holds the stale copy. If not, the old value lives elsewhere and this guide does not apply. Do not flush the whole cache to find out: every reader then misses at once and the database takes the load.
Next, decide how stale a value may be and for how long. A price shown a few seconds late is usually fine; a permission change that takes minutes is not. The answer decides whether a cache is appropriate for this object at all.
- Delete one key, not the cache.
- Write the allowed staleness window as a number.
Invalidating before the commit
The most common bug is order. The code deletes the cache key, then writes to the database. Between the two, a concurrent reader finds no cached value, reads the old row, and puts it back in the cache. The database then commits the new value, but the cache now holds the old one, and nothing will remove it until it expires. Celery's documentation describes the same ordering problem for queued tasks, which can start before the transaction commits and see the old state; applying that to a cache is our own reading, not something that page says.
The fix is to invalidate after the transaction commits, using your framework's after-commit hook, and to test it with a reader paused between its database read and its cache write. Even then, a reader that started before the commit can store the old value after the invalidation, and it stays until its entry expires. So the expiry is the window: choose it as a number of seconds, give every entry that expiry, and test that the old value is gone by then. A versioned key closes the window further: the writer increments a version number in Redis after the commit, readers put the version in the key, and a late refill lands in an entry nobody reads.
- Delete or rewrite the key in the after-commit step.
- Give every entry an expiry no longer than the window you agreed, so any missed case heals itself.
Missed paths, dropped expiry and mismatched keys
Objects change in more places than the one you remember: an admin tool, a background job, an import, a database script. List every write path, and make sure each one invalidates. The paid job produces that list as a table, because the missing path is usually the cause.
Expiry has a trap of its own. The Redis documentation says SET discards any earlier time to live, so refreshing a cached value with a plain SET turns an entry that was meant to expire into one that lives forever, unless you pass KEEPTTL, available since Redis 6.0.0, or set a new expiry in the same command. Commands that alter a value in place, like INCR or HSET, leave the expiry alone. Finally, check that the writer and the reader build the key the same way: a difference in case, a missing tenant id or a version prefix is enough to make them look at different entries.
- Search the code for every place that saves or deletes the object.
- State the expiry policy after an overwrite, and test it.
- Build the key in one function used by both reader and writer.
Do not delete by pattern in production, and what the job covers
It is tempting to clear many related keys with a pattern. Redis's own page for KEYS warns that it is O(N) in the number of keys, may ruin performance on large databases, and should not be used in application code; it suggests SCAN or sets. SCAN spreads the work across calls but only guarantees that elements present throughout are returned, may return one more than once, and may or may not return those added or removed during the iteration. A better design keeps a set of the keys belonging to an object, or versions the key so old entries simply stop being read.
The stale-cache outcome corrects the invalidation order, expiry handling and key construction for one object type on staging, with tests that include a reader paused between its database read and its cache write and that fail on the original code. It does not tune Redis, change eviction, or clear keys in production, and it states the window in which an old value can still be seen as a number of seconds that a test enforces.
- Prefer a key set or a versioned key over a pattern delete.
- Never run a pattern delete on production as a quick fix.
Sources and limits
- Redis: SET command Checked 2026-10-11.
- SET overwrites a key and discards any previous time to live, unless KEEPTTL (since Redis 6.0.0) is given.
- Redis: EXPIRE command Checked 2026-10-11.
- The timeout is cleared by commands that delete or overwrite the key, such as DEL and SET, while commands like INCR, LPUSH and HSET leave it untouched.
- Keys are expired passively when accessed and actively by periodic sampling.
- Redis: KEYS command Checked 2026-10-11.
- KEYS is O(N) in the number of keys, may ruin performance on large databases in production, and the page suggests SCAN or sets instead.
- Redis: SCAN command Checked 2026-10-11.
- A full SCAN iteration returns all elements present throughout, but an element may be returned more than once and elements added or removed during the iteration may or may not appear.
- Celery: Tasks Checked 2026-10-11.
- Work queued inside a database transaction can start before the commit, so it should be sent after the commit; delay_on_commit, added in Celery 5.4, does that. The page does not mention caches.
- Redis: INCR command Checked 2026-10-11.
- INCR atomically increments the integer stored at a key by one, setting a missing key to 0 first.