Engineering
Redis Date-Keyed Cache: Shipping a Problem of the Day
Abhishek Bahukhandi

A Redis date-keyed cache is the obvious shape for a problem of the day: put the calendar date in the key, give it a TTL, and let midnight take care of the rest. We shipped that for Taqari's daily coding challenge and the key design held up fine. The word that did not hold up was "midnight".
Everything below is what the date-keyed approach actually costs: who is allowed to decide which day it is, why a TTL is a backstop rather than a rollover mechanism, and how one shared cache quietly lets staging publish tomorrow's problem into production.
What a Redis date-keyed cache has to guarantee
Before any Redis command, it is worth writing down what the feature promises. Ours promises three things, and every bug we hit was one of them breaking.
- One problem per day, globally. Two users opening the challenge at the same moment see the same problem, wherever they are.
- A day boundary that is decided once. Not once per client, not once per service — once.
- Stability while someone is solving. A reseed must never swap the problem out from under a session in progress.
The third one is the reason the date belongs in the key rather than in the value. If today's problem lives at a key whose name contains the date, a new day writes a new key and the old one simply stops being addressed. Nothing is mutated, so nothing changes under a reader. Put the date inside the value instead and every publish is an overwrite of the one key everybody is reading.
The key name is the rollover mechanism. The TTL is only cleanup. If you find yourself relying on expiry timing to make the right problem appear, the date is in the wrong place.
Who decides which day it is
Our first version let the browser decide. The submission hook built the date key itself and sent it along with the code:
const today = new Date();
const dateKey =
today.getUTCFullYear().toString() +
String(today.getUTCMonth() + 1).padStart(2, '0') +
String(today.getUTCDate()).padStart(2, '0');
// POST { sourceCode, language, dateKey }
Using the UTC getters was a deliberate choice and it does buy something real: every client in the world computes the same string for the same instant, so two users in different zones agree. That is better than the local-time version of the same code, which would have given users in Tokyo and Los Angeles different problems for several hours a day.
Why the client cannot own the day boundary
It still had two problems. The first is product-shaped: UTC midnight is 05:30 in India, where most of our users are. A challenge that rolls over at half past five in the morning is defensible; one that rolls over at 05:30 because nobody chose a timezone is an accident. The boundary needs to be a decision, written down in one place.
The second is that a client-supplied date key is a client-supplied cache key. A machine with a wrong clock sends a wrong key and gets a confusing answer. Nothing catastrophic — a wrong date just misses — but the server has no way to distinguish a skewed clock from a stale tab, and the user sees a failure that nobody can reproduce.
So the day boundary moved to the server. The client now sends what it genuinely knows — the code, the language, and in the newer flow an explicit question id when the user picked a specific problem rather than today's — and the server resolves the date itself. Those two paths are worth keeping separate: a chosen question is addressed by its own identifier and needs no date at all, while the daily flow is the only thing that needs a calendar.
Resolving the day in one fixed timezone
Once the server owns the boundary, it needs to turn an instant into a calendar date in a named zone. The tempting one-liner is wrong: MDN is explicit that toISOString() always renders in UTC, denoted by the trailing Z, so slicing the first ten characters off it gives you a UTC date no matter what you intended.
The correct tool is Intl.DateTimeFormat with an explicit timeZone, read back through formatToParts so you get fields instead of a human-readable string you have to parse:
const ZONE = 'Asia/Kolkata';
function resolveDateKey(instant) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: ZONE,
year: 'numeric', month: '2-digit', day: '2-digit',
}).formatToParts(instant);
const get = (t) => parts.find((p) => p.type === t).value;
return get('year') + get('month') + get('day');
}
The date key
Two details earn their keep. Ask for 2-digit month and day so the key is fixed-width and sorts lexicographically — useful the first time you scan for yesterday's keys. And read the parts rather than the formatted string, because the separator and field order of a locale are not your API.
The expiry instant
The same named zone also has to produce the moment the key should die, which is the next boundary in that zone, converted to a Unix timestamp. Deriving both the key and the expiry from one ZONE constant is the whole point: when someone eventually changes the boundary, there is exactly one line to change, and the key and its expiry cannot disagree.
How a submission finds today's problem
Here is the path a run takes, with the date resolved server-side and the cache sitting in front of the database that actually owns the problem set.
The submission answers with a run id and the number of test cases, and the per-case verdicts arrive afterwards over a stream rather than in the response body. We wrote about that half of the system separately, in streaming test results with Server-Sent Events and Redis — the relevant point here is that the date key is resolved exactly once, at submission, and then the run carries its own identity. A run that started before the boundary keeps judging against the problem it started with.
Three ways a TTL betrays you
The expiry is the part that looks like one argument and behaves like a feature.
A plain SET discards the TTL you already set
This is the trap that bites everyone exactly once. The Redis SET documentation says it plainly: any previous time to live associated with the key is discarded on a successful SET. So a seeding script that writes the value with an expiry, and a later fix-up that rewrites the value with a bare SET, leaves you with a correct value and no expiry at all — an immortal key that nothing will ever clean up.
There are two honest ways out, and they mean different things:
KEEPTTLretains the expiry that is already there. Right for correcting a value inside the day it belongs to.EXATre-states the expiry on every write. Right for seeding, because the write is self-contained and cannot depend on what a previous run did.
The EXPIRE documentation makes the complement explicit: a timeout is only cleared by commands that delete or overwrite the contents of a key, so operations that alter a value in place — incrementing a counter, setting a hash field — leave the expiry untouched. If today's problem is a hash and you update one field of it, the expiry survives. The same key written as a string by a plain SET does not.
Relative TTLs drift, absolute expiry does not
A relative TTL is measured from the moment of the write. Seed at 19:00 with a 24-hour TTL and the key outlives its day by 19 hours; reseed it twice and it outlives it further. Nothing breaks loudly, because the key name is still doing the real work — you just accumulate yesterdays in memory and lose the ability to reason about what is in the cache.
Absolute expiry fixes it. EXAT on the write, or EXPIREAT afterwards, takes the Unix timestamp of the next boundary, so the key dies at the same instant whether it was seeded a week early or thirty seconds ago. Reseeding is then idempotent in both the value and the expiry.
One sharp edge is worth knowing before you compute that timestamp. The EXPIRE docs note that an expiry in the past results in the key being deleted rather than expired, and the event Redis emits is del, not expired. Get a timezone offset backwards while building the boundary and you do not get a wrong expiry — you get a key that vanishes on write, and a keyspace listener watching for expired that never fires.
Guarding an expiry you do not want to shorten
EXPIRE also takes NX, XX, GT and LT — set the expiry only if there is none, only if there is one, or only if the new one is further out or nearer than the current one. For a date-keyed cache, GT is the useful one: a repair job can push an expiry out without any chance of accidentally pulling today's problem forward into the past. Note that a key with no expiry counts as infinite for GT and LT, which is usually what you want and occasionally is not.
Telling a missing key from a key that lost its expiry
These two failures look identical from the application and are completely different incidents. Redis will tell you which one you have: the TTL command returns -2 when the key does not exist, and -1 when the key exists but has no associated expire.
We check both in the health probe for the daily challenge. A -2 for today means a seeding gap: nobody published a problem, and the fallback path is about to carry all the traffic. A -1 means some write dropped the expiry, and that key will sit there forever. Collapsing them into "cache problem" loses the only information that tells you which thing to go fix.
Seeding tomorrow without disturbing today
Seeding is a write into the future, which makes it the least-tested path in the system and the one most likely to run twice. Three rules have kept it dull:
- Seed by key, never by "tomorrow". The seeder resolves a date key through the same function the request path uses and writes that key. A job that computes its own notion of tomorrow is a second day-boundary implementation, and the two will disagree eventually.
- Make the first writer win.
SETwithNXonly writes if the key does not exist, so a job that runs twice — or a retry overlapping with the original — publishes once. Overwriting a day that may already be in progress is a strictly worse default than failing to publish. - Verify by reading back the key you wrote. Not "the seed job exited zero" — read the key and check the value and the remaining TTL. The expiry is as much a part of a correct publish as the value is.
Pre-seeding several days ahead is tempting and mostly good: it turns a midnight failure into a scheduled-job alert you can answer at a sensible hour. The cost is that a mistake is now published for several days instead of one, so the correction path — KEEPTTL on a value fix, within the day — has to work before you lean on the buffer.
One Redis, two environments
Our most embarrassing incident in this area had nothing to do with time. A staging deploy pointed at the same cache as production, ran its seeder, and published a test problem to real users. The date key was computed perfectly. It was simply the same key.
Prefix every key with the environment and make the prefix impossible to omit — one helper that builds keys, no raw string concatenation at call sites. Separate logical databases are not enough in a clustered setup and are easy to misconfigure into the same place; a prefix survives being moved between deployments, and it makes a wrong key obvious the first time someone looks at it.
The cache is not the source of record
The deeper fix is to stop the cache being load-bearing. A date-keyed value can disappear before its expiry: under memory pressure an eviction policy removes keys that still have time on them, and a restart without persistence loses the lot. If a missing key means an error page, then your daily challenge is exactly as available as your cache.
So the problem set lives in the database, and the cache holds today's resolved selection. A miss re-derives the selection from the database, writes it back with NX and an absolute expiry, and serves it. That makes a miss slow rather than fatal — and it means the environment-prefix bug above could only ever have published a wrong problem, not destroyed the ability to publish one. The same instinct runs through how we handle the judge: a fuller version of it is in our write-up on assembling per-test-case results from batch submissions, where a lost callback must also degrade into something slower rather than something broken.
What we would do differently
Three things, in order of how much pain they would have saved.
Put the boundary in a constant on day one. Not a timezone-aware date library — just one named zone, in one module, used by both the request path and the seeder. Almost every bug above started as two pieces of code having their own opinion about when the day ends.
Write expiry as an instant, never a duration. EXAT and EXPIREAT make a reseed idempotent and make the cache's contents something you can reason about. A relative TTL is only correct at the moment you write it.
Treat the pair as the unit. A value without the right expiry is a half-finished publish. Every write states both, every read-back checks both, and the health probe distinguishes -1 from -2. Daily content is one of the rare places where the metadata is as important as the data.
None of this is exotic. It is the ordinary cost of letting a calendar into your key space — and the reason the cheapest version of a daily feature is still the one where the key name, not the clock, decides what you see. If you want to see the thing itself rather than read about it, the daily challenge and the AI mock interviews are both free to try.
Frequently asked questions
What is a date-keyed cache in Redis?
+
It is a cache where the calendar day is part of the key name, so a new day addresses a new key instead of overwriting the old one. Rollover becomes free: the moment the resolved date changes, yesterday's value is unreachable rather than stale, and its TTL is only cleanup.
Does Redis SET reset the TTL on a key?
+
Yes. The Redis SET documentation states that any previous time to live associated with the key is discarded on a successful SET. Re-seeding a key with a plain SET therefore makes it immortal unless you pass EX or EXAT again, or pass KEEPTTL to retain the existing expiry.
Should a daily cache key use a relative TTL or an absolute expiry?
+
Use absolute expiry. EXAT and EXPIREAT take a Unix timestamp, so the key dies at a specific instant no matter when it was written. A relative TTL measured from write time drifts with every reseed, so a key written late in the day outlives the day it belongs to.
How do you compute a day boundary in a fixed timezone in JavaScript?
+
Do not use the UTC getters or toISOString, which MDN notes is always UTC. Format the instant with Intl.DateTimeFormat using an explicit timeZone option and read the year, month and day back out of formatToParts, so one named zone defines the day for every user.
How can you tell a missing cache key from a key that lost its expiry?
+
Ask Redis. The TTL command returns -2 when the key does not exist and -1 when the key exists but has no associated expire. Those are different incidents: the first is a seeding gap, the second is a write that dropped the expiry and left a key that will never roll over.
Can a cached daily problem disappear before its TTL?
+
Yes. Under memory pressure an eviction policy can remove a key that still has time left, so any date-keyed value must be rebuildable from the database that actually owns it. Treat the cache as derived state and make a miss a slow path, never an error.
Sources
- Redis — SET command documentation (EX, EXAT, NX, KEEPTTL and TTL discard behaviour)
- Redis — EXPIRE command documentation (NX, XX, GT, LT and expiry in the past)
- Redis — TTL command documentation (-1 and -2 return values)
- MDN Web Docs — Date.prototype.toISOString() is always UTC
- MDN Web Docs — Intl.DateTimeFormat and the timeZone option
Keep reading
AI Interviewer System Prompt: Rules That Actually Stick
An AI interviewer system prompt alone won't stop a model lecturing or leaking the answer. Here are the config limits and tools that make the rules stick.
10 min readEngineeringServer-Sent Events with Redis: Streaming Test Results
How we stream code-execution results to the browser using Server-Sent Events with Redis behind them: keying run state, reconnecting a tab, closing cleanly.
10 min readEngineeringUnderstanding Time Value and Date Comparison in JavaScript
Learn what time value means in JavaScript and how to compare dates correctly using time values.
7 min readPreparing for an interview?
Start with these guides, then practise a full round with the AI interviewer.