Intermediate30 min readMachine Coding Practicelive prototype

In-memory key-value store

The sibling cache problem asks what to throw away when you run out of room. This one asks something stranger: when does a key stop being real? A key with a TTL has an expiry instant — and nothing runs when that instant arrives. The key is still in the map, still counted, still holding memory. It only becomes gone when somebody looks. Everything hard here follows from that one sentence.

The idea

What it is

“Design an in-memory key-value store. It should support TTL.” Two sentences, and the second one is the whole interview. Without it you are writing a wrapper around a hash map and you will be finished in nine minutes.

With it, you are writing a miniature Redis. Somebody hands you a key, a value, and a number of seconds, and from that moment the store owes them a promise: after those seconds, this key is gone. The interesting question is what “gone” means when there is no thread standing over the key with a stopwatch.

Client get("session:1") set(k, v, ttl=5s) expire(k, 30s) the API surface IS the design KeyValueStore one ReadWriteLock (or N shard locks) Map<String, Entry> "session:1" → Entry("abc", expiresAt=5000) "cart:42" → Entry("[…]", expiresAt=8000) "user:7" → Entry("nina", expiresAt=∞) "report:19" → Entry("…", expiresAt=3000) expired at t=3000 · still here · size() still says 4 Entry «value object» value + expiresAt an INSTANT, not a countdown ActiveExpirer samples 20 keys, repeats if > 25% dead Clock «interface» nowMillis() · injected memory used maxMemory past this line a key dies for SPACE, not for time — a different mechanism entirely
Look at the fourth row. It is grey, struck through, and still there — its expiry passed at t=3000 and nothing happened. That row is the lesson.

The whole system in three sentences

An Entry holds a value and an absolute expiresAt instant. A get reads the map, compares expiresAt to now, and if the instant has passed it deletes the key and reports a miss — that is lazy expiry. A background sweeper samples random keys with TTLs and deletes the dead ones, because a key nobody ever reads again would otherwise sit in memory forever.

That is roughly 200 lines of code and it is genuinely all of it. What separates a good round from a mediocre one is whether you notice the strange middle state — a key that is logically gone but physically present — and whether you can say out loud why every real cache server lives with it.

What is actually being graded

  1. Is a TTL an instant or a countdown? Storing expiresAt = now + ttl once is right. Storing secondsLeft and decrementing it means something has to do the decrementing, for every key, forever.
  2. Do you know that expiry is not an event? Nothing fires when a key expires. If you describe a Timer per key, or a callback, the round is effectively over — a million keys would mean a million timers.
  3. Do you have both strategies? Lazy expiry alone leaks any key nobody reads again. A full O(n) sweep alone stalls the server. The answer is lazy plus sampled active expiry, and being able to describe the sampling loop is a standout moment.
  4. Is the API honest? ttl(key) must distinguish three states — remaining time, exists but never expires, and no such key. A method that returns -1 for two different situations is a bad API, and interviewers notice.
  5. Does it run, and is the clock injectable? get(key) reading a Clock interface means your TTL tests are three lines and instant. System.currentTimeMillis() sprinkled through the code means every test needs a Thread.sleep.

Mechanics

How it works

Step 1 · Clarify — 4 minutes

  • Single process, or a cluster? — say single process, and offer the distributed version as a follow-up. In-process means one lock is a real answer; across machines it is not an answer at all.
  • Are values just strings? — assume strings, and say you would make Entry.value a sealed type if they wanted lists, sets and hashes. It does not change the design, which is worth saying because they are checking whether you think it does.
  • Is there a memory limit? — yes, and this is the question that gets you the eviction conversation for free. Ask it early.
  • Does it need to survive a restart? — usually no, but ask. If yes you get an append-only log and a snapshot, which is a whole extra section you can control the size of.
  • How precise must expiry be? — this is the good question. “Is it acceptable that a key which expired 200ms ago is still using memory, as long as nobody can read it?” If they say yes — and they will — you have just been handed permission to build the lazy plus sampled design.
✓ IN SCOPE — say these in the first two minutes get · set · delete · exists · size setWithTtl · expire · ttl · persist lazy expiry on every read sampled background sweeper memory bound + eviction policy thread safety (ReadWriteLock) injected Clock, so TTL is testable the two orange lines are what the problem is for ✗ OUT OF SCOPE — name them, do not build them clustering · replication · failover the wire protocol / network layer pub-sub and keyspace notifications lists, sets, hashes, sorted sets real transactions with rollback durability beyond a two-minute sketch auth, ACLs, metrics endpoints each one is a follow-up you can pull in if time allows
Reading the right-hand column out loud takes twenty seconds and buys you the whole hour. Naming what you are not building is how you protect the time to build what you are.

Do not let TTL become an afterthought

Candidates build get, set and delete in eight minutes, feel good, and then bolt TTL on at minute forty with a Timer. Build the Entry with expiresAt in it from the first line. TTL is not a feature of this problem — it is the problem, and every design decision that follows is downstream of how you represent it.

Step 2 · A TTL is an instant, not a countdown

The user says “expire this in 5 seconds.” You store expiresAt = clock.nowMillis() + 5000 — one addition, once, at write time. From then on the question “is this key alive?” is a single comparison: now < expiresAt. No arithmetic runs in between. No thread wakes up. The key does not know how long it has left and does not need to.

The alternative — a secondsLeft field, or a Timer/ScheduledFuture per key — feels more direct and is the single most common way to lose this round.

✓ AN ABSOLUTE INSTANT final class Entry { final String value; final long expiresAtMillis; // Long.MAX_VALUE = never expires } alive? → now < expiresAt (one compare) 8 extra bytes per key nothing is running · nothing to cancel immutable — a new TTL makes a new Entry ✗ A COUNTDOWN, OR A TIMER PER KEY class Entry { String value; int secondsLeft; ScheduledFuture<?> timer; } 1,000,000 keys → 1,000,000 timers or a thread ticking every key, every second every overwrite must cancel the old timer and a paused process wakes up with wrong counts
The right-hand box is not slower by a constant factor — it is O(keys) of live machinery to answer a question that the left-hand box answers with one subtraction, only when asked.

Which clock, though?

Use a monotonic source where you can (System.nanoTime, time.monotonic()), because wall-clock time can jump backwards when NTP corrects it and a key would then un-expire. In the interview, wrap whichever you pick behind a Clock interface and move on — the point you are making is that the store never calls a static time function directly. That is plain Dependency Injection & IoC, and here it is also the only thing that makes TTL testable without sleeping.

Step 3 · Expired is not deleted — the idea this whole problem turns on

Follow one key. At t=0 you call set("k", "v", ttl=5s), so expiresAt = 5000. At t=5000 the key expires. Ask yourself what code runs at that moment. The answer is: none. There is no timer, no callback, no thread watching. The clock simply moves past a number stored in a field.

So at t=6000 the key is still in the map. size() still counts it. Its bytes are still resident. It is logically gone and physically present, and it stays in that state — possibly for hours — until somebody looks at it.

ONE KEY, TEN SECONDS — watch when it actually leaves the map set("k","v",ttl=5s) expiresAt = 5000 t = 5000 · the expiry instant NOTHING RUNS HERE no timer · no callback · no thread get("k", now) → (nil) in the map "k" → Entry("v", 5000) · LIVE "k" → Entry("v", 5000) expired-but-present · still costing memory (map is empty) 0 1 2 3 4 5 6 7 8 9 seconds size() 1 1 1 1 1 1 1 1 1 0 get returns "v" (nil) (nil) The key was unreadable from t=5 — but it did not leave the map until a reader arrived at t=9. Four seconds of memory held by something no caller could ever see. That gap is what the sweeper exists to close.
Read the size() row. It says 1 for four seconds after the key stopped being readable. Correctness and memory are two different questions here, and this figure is the moment they separate.

The sentence that wins this round

“Nothing happens at the expiry instant. The key is unreadable from that moment because every read checks expiresAt, but it does not leave the map until either somebody reads it or the sweeper samples it.” Say that in the first ten minutes and the rest of the hour is a conversation between equals.

Step 4 · Two expiry strategies, and real systems use both

Lazy expiry is the one you get almost for free. Every get already looks the key up; adding one comparison to that lookup is free in every sense that matters. If expiresAt has passed, you delete the key right there and report a miss. The caller can never observe a stale value, and you never spend a single cycle on keys nobody asks about.

And that last clause is also the hole. Consider a key called report:2019-q3 with a one-hour TTL, written once and never read again. Its hour passes. Nobody calls get. Nothing deletes it. It sits in memory until the process restarts — and a workload full of such keys will run the server out of RAM while size() cheerfully reports a number that is mostly ghosts.

LAZY — check on every read hot key "session:1" · read every 2s expires → next get deletes it → 0 bytes held cold key "report:2019-q3" · never read again expires → nobody looks → still there an hour later → STILL THERE until the process restarts → leaked memory ▲▲▲ · size() counts ghosts ACTIVE — a sampling sweeper every 100ms, on its own thread: draw 20 random keys THAT HAVE A TTL delete the expired ones > 25% dead? → immediately go again cold key found → reclaimed cost: O(sample), not O(n) — never scan every key memory ▼ · but expiry is now approximate in time REAL SYSTEMS DO BOTH. Lazy is exact for anything anyone reads; the sweeper handles everything else. Memcached is famously lazy-only and relies on eviction to reclaim the rest. Redis runs both, on a loop, forever.
Neither column is a complete answer on its own. Say “both, and here is why neither alone works” — that is the graded sentence.

So the sweeper has to run. The naive sweeper walks every key, checks every expiresAt, and deletes what is dead. On a store with ten million keys that walk takes long enough to be visible to every client, and it does it over and over to find the handful of keys that died since last time. You do not need a complete answer, you need a statistical one.

THE SWEEPER LOOP — this is Redis's actual algorithm 1 · draw 20 random keys with a TTL 2 · delete the expired ones 3 · was more than 25% expired? YES → go again now NO 4 · sleep 100ms, then start over Bounded work per pass. The CPU it spends is proportional to how much junk it keeps finding — not to how many keys the store holds. ONE SAMPLE, DRAWN ■ expired = 6 □ still alive = 14 these 20 were picked at random from the keys that have a TTL 6 / 20 = 30% · 30% > 25% → SAMPLE AGAIN, NOW A dense sample means there is probably more dead weight nearby, so it is worth another pass. A sparse one means stop and sleep. The guarantee this gives you is probabilistic: at any moment fewer than ~25% of the keys with a TTL are expired-but-present. Not zero. Bounded.
The 25% rule is the clever bit: it makes the sweeper's cost track the amount of garbage, not the size of the store. Describe this loop out loud and you will be the only candidate that day who did.
the sweeper, in full
/**
 * One pass of active expiry. Bounded work, then it returns.
 * A scheduler calls this every 100ms; it never walks the whole keyspace.
 */
int sweepOnce() {
    int totalRemoved = 0;

    for (int round = 0; round < MAX_ROUNDS; round++) {      // hard cap, so a pass always ends
        List<String> sample = randomKeysWithTtl(SAMPLE_SIZE);   // 20
        if (sample.isEmpty()) break;

        int expired = 0;
        lock.writeLock().lock();
        try {
            long now = clock.nowMillis();
            for (String key : sample) {
                Entry e = map.get(key);
                if (e != null && e.isExpired(now)) { map.remove(key); expired++; }
            }
        } finally {
            lock.writeLock().unlock();
        }
        totalRemoved += expired;

        // "was more than 25% of the sample dead?"  expired/size > 0.25  without floats
        if (expired * 4 <= sample.size()) break;            // sparse → stop, sleep, try later
    }
    return totalRemoved;
}

Why sample only the keys that have a TTL

Most stores hold a mix: some keys expire, most do not. Sampling the whole keyspace would waste almost every draw on keys that can never be expired. Keep a second set — Redis calls it the expires dict — holding just the keys with a deadline, and sample from that. It costs one extra insert on setWithTtl and it makes the sweeper an order of magnitude more effective.

Step 5 · The API surface is the design

Write the method list on the board before you write any bodies. It takes two minutes, it is the artefact the interviewer will actually discuss with you, and two of the rows on it are where careless designs get caught.

method returns the edge case that is graded get(key) Optional<String> expired → miss, and delete it here (lazy) set(key, value) void CLEARS any existing TTL — the key becomes permanent setWithTtl(key, value, ttl) void stores now + ttl as an absolute instant delete(key) boolean false if it was already expired — do not lie exists(key) boolean must apply the SAME expiry check as get() expire(key, ttl) boolean false if there is no such live key persist(key) boolean removes the deadline, keeps the value ttl(key) TtlResult — 3 states remaining · NO_EXPIRY · NO_SUCH_KEY keys(pattern) List<String> O(n) and blocking — this is why SCAN exists size() int PHYSICAL count — includes expired-but-present The two shaded rows are the ones interviewers push on. Everything else is bookkeeping.
Two shaded rows. The red one is a bug people ship to production; the orange one is a design smell that costs nothing to avoid. Volunteer both before you are asked.

set clears the TTL — and that has broken real systems

A session key is written with a 30-minute TTL. Later some unrelated code refreshes the value with a plain set(key, value) — and the session becomes immortal. The user is logged in forever and the memory is never reclaimed. Redis behaves exactly this way, which is why it grew a KEEPTTL option. Offer the same choice: set(key, value, keepTtl), defaulting to clearing, and say the sentence “a plain set makes the key permanent” out loud.

The second shaded row is ttl(key). There are genuinely three answers it can give, and they mean completely different things: “1,842 milliseconds left”, “this key exists and will never expire”, and “there is no such key.” Collapse two of those into one sentinel and the caller cannot tell a permanent key from a missing one — which is exactly the bug that makes people write if (ttl(k) == -1) recreate(k) and then wipe a key that was fine.

an honest ttl()
/** Three distinguishable states. No caller ever has to guess. */
sealed interface TtlResult {
    record Remaining(long millis) implements TtlResult {}
    record NoExpiry()             implements TtlResult {}   // key is there, no deadline
    record NoSuchKey()            implements TtlResult {}   // key is absent OR expired
}

TtlResult ttl(String key) {
    long now = clock.nowMillis();
    lock.readLock().lock();
    try {
        Entry e = map.get(key);
        if (e == null || e.isExpired(now)) return new TtlResult.NoSuchKey();
        if (e.neverExpires())               return new TtlResult.NoExpiry();
        return new TtlResult.Remaining(e.expiresAtMillis() - now);
    } finally {
        lock.readLock().unlock();
    }
}

// Redis encodes the same three states as -2 (no key), -1 (no expiry), n (millis left).
// That works, but only because it is documented. A type says it without a manual.

The keys(pattern) trap

keys("session:*") has to walk every key in the store, and in a single-threaded server it holds the whole thing still while it does. That is why Redis's own documentation tells you not to run KEYS in production and offers SCAN instead — a cursor: each call returns a few keys and a position to resume from, so the work is spread across many small calls. Mention it in one sentence: “I would ship keys() for the interview and a cursor-based scan() for production, because keys() is O(n) and blocking.”

Step 6 · Concurrency — where the lazy delete bites

A key-value store is read-heavy: many gets, few sets. That is the textbook case for a Read-Write locks — any number of readers together, or one writer alone. And here, unlike in Cache (LRU / LFU) where a get secretly rewrites the recency list and so a read lock buys you nothing, a get in a plain store really is a read. It looks up a key and compares two numbers. Nothing moves.

Except when the key it finds has expired. Then the read has to delete it — and deleting is a write. That is the one interesting concurrency moment in the whole problem, and it is worth being precise about.

NORMAL — a get really is a read here readLock held by three threads at once reader A reader B reader C writer W — waiting outside writeLock held by exactly one thread writer W — set / delete / sweep reader A reader B reader C reads far outnumber writes → this actually pays THE CATCH — a get that finds an EXPIRED key must WRITE readLock · map.get(k) expiresAt < now ✗ release readLock (Java cannot upgrade in place) acquire writeLock RE-CHECK the entry map.remove(k) return (nil) Why the re-check is not optional: in the gap between releasing the read lock and taking the write lock, another thread may have set(k) a brand-new value. Removing blindly would delete live data. Simpler alternative worth naming: keep one plain lock, or use ConcurrentHashMap and do the whole thing in a single atomic computeIfPresent — return null from the remapping function and the map deletes the entry for you.
The red sentence is the whole point. Release-then-reacquire is not an upgrade — it is a gap — and any state you read before the gap must be read again after it.
Client KeyValueStore Clock Map + ReadWriteLock get("session:1") nowMillis() 6000 readLock · map.get Entry(expiresAt=5000) read under readLock 5000 < 6000 → expired · this read must now WRITE unlock read · lock write RE-CHECK the entry map.remove · unlock removed · lazyExpired++ Optional.empty() — a MISS, never a stale value
Trace the orange band. A read that turns into a write is the only place this design has a sharp edge — and pointing at it before the interviewer does is worth more than any other single sentence here. Notation: Sequence diagrams.
  • The 60-minute answer: one ReentrantReadWriteLock, get takes the read lock, and the lazy delete drops the read lock and takes the write lock with a re-check. Ten lines, correct, and you can explain it.
  • The tidier answer: a ConcurrentHashMap and computeIfPresent(key, (k, e) -> e.isExpired(now) ? null : e). Returning null from the remapping function removes the entry, so the check and the delete are one atomic step on that bin — no explicit lock at all. Related: Atomic operations & CAS.
  • The answer you must not give: synchronized on every method. It works, and it makes a read-heavy store serialise every single get behind every other one. Background: Locks, Mutex, Semaphore.
  • How you scale past one lock: shard the keyspace. shardFor(key) = shards[hash(key) & (N-1)], each shard holding its own map and its own lock. Keys in different shards never contend, so N writers can proceed at once.
✗ ONE LOCK — four writers, one queue w1 w2 w3 🔒 one lock one map w2 and w3 wait even though they touch other keys ✓ FOUR SHARDS — four writers, at once shardFor(key) = shards[hash(key) & 3] shard 0🔒 own lockown mapw1 inside shard 1🔒 own lockown mapw2 inside shard 2🔒 own lockown mapw3 inside shard 3🔒 own lockown mapw4 inside contention drops by roughly N — this is what ConcurrentHashMap does internally The bill for sharding: anything global now costs N visits. size() sums N counters · keys(pattern) walks N maps · the sweeper must sample across N shards and a snapshot of "the whole store" is no longer a single consistent instant unless you lock them all. None of this survives leaving the process: a lock guarantees nothing across a cluster of machines.
Sharding is the honest answer to “won't one lock be a bottleneck?” — but say the bill out loud too. Every global operation gets more expensive, and correctness across machines needs something else entirely.

The class diagram

KeyValueStore + get · set · setWithTtl · delete + expire · persist · ttl · keys · size + incr · compareAndSet 1..N Shard - map : Map<String, Entry> - lock : ReadWriteLock - volatileKeys : Set<String> only these are ever sampled Entry «value object» - value : String - expiresAtMillis : long Clock «interface» + nowMillis() : long SystemClock FakeClock FakeClock is why TTL tests take microseconds, not seconds ActiveExpirer + sweepOnce() : int EvictionPolicy «interface» + pickVictim(shard) : String NoEviction VolatileTtl AllKeysLru AllKeysLru is where the sibling problem lives — this store just calls pickVictim() Four seams, and only one of them is really needed in 60 minutes: the Clock.
Notice how small the store itself is. The Clock seam is the one that earns its keep in an interview — the eviction seam is the one to name and only build if there is time. Notation: Class diagrams.

The memory bound — dying of space, not of time

A key can die two completely different deaths, and conflating them is a classic slip. Expiry is about time: this key had a deadline and the deadline passed. Eviction is about space: the store is full and something has to go, whether or not it had a deadline at all. Different trigger, different mechanism, different policy.

  • noeviction — refuse writes once the limit is hit. Correct for a store you are using as a database, infuriating for a cache. This should be your default answer, because it is the only one that never silently loses data.
  • allkeys-lru — throw out the least recently used key, TTL or not. The right choice when the store is purely a cache. The machinery for it is the entire sibling problem — see Cache (LRU / LFU) — so here you just call policy.pickVictim() and move on.
  • volatile-ttl — among the keys that already have a deadline, drop the one expiring soonest. Elegant, because you are killing something that was going to die anyway, and it never touches a key the user marked permanent.
  • random / allkeys-random — pick a victim at random. Sounds lazy; it is genuinely reasonable when access has no locality, and it costs nothing to implement.

Bytes, not entries

maxEntries is fine for a whiteboard and wrong in production: 10,000 entries could be 4 MB or 4 GB. Real stores take a weigher — a function from an entry to its size — and evict while totalWeight > maxMemory. It is the same loop with a different comparison, and saying it takes ten seconds.

Durability — what survives a restart

An in-memory store loses everything when the process dies. Sometimes that is fine. When it is not, there are exactly two mechanisms, and real systems run both.

APPEND-ONLY LOG — every write, in order SET session:1 abc EX 5 SET user:7 nina DEL cart:42 SET user:7 nina2 … append, never rewrite boot → replay from line 1 → exact state grows forever · needs compaction compaction = rewrite it as the minimum set of SETs SNAPSHOT — the whole map, every N seconds snap snap snap 💥 crash LOST WINDOW compact · loads in one pass · cheap to ship loses everything since the last snapshot and writing it forks the process / doubles memory briefly TTLs are stored as absolute instants, so they survive correctly What fsync buys and costs: an appended line sits in the OS page cache until fsync pushes it to the disk. fsync every write = nothing is ever lost, and your write rate is now the disk's write rate. fsync once a second = you can lose one second, and writes stay in-memory fast. Almost everyone picks the second.
The red band on the right is the honest number: a snapshot every 60 seconds means “I can lose 60 seconds.” Say the window out loud rather than saying “it's durable” — durability is always a number.

Atomic operations, and why they belong in the store

“Can two clients both increment a counter?” If the only API is get then set, the answer is no — client A reads 5, client B reads 5, both write 6, and one increment vanishes. This is the same lost-update shape as two gates grabbing one spot in Parking Lot, and the fix is the same: the read-modify-write has to happen inside the store, under its lock, as one operation.

  • incr(key, delta) — parse, add, store, return the new value, all under one write lock. One method, and it removes an entire class of client bug. Note that it must decide what a missing key means: treat it as 0, and say so.
  • compareAndSet(key, expected, next) — write only if the current value is still what the caller last read. This is optimistic concurrency: the caller retries on failure instead of holding a lock. Same primitive as Atomic operations & CAS, one level up.
  • MULTI-style batching — queue several commands and run them back to back with nothing interleaved. Note the honest limitation: that gives you isolation, not rollback. If command three fails, commands one and two have already happened. Redis is explicit about this, and being explicit about it yourself reads very well.

The 60 minutes

0 min 60 min clarify 5 map + Entry + LAZY expiry in get() 14 sampling sweeper 10 eviction hook + main() + run it 10 API list + Entry + Clock 6 expire · persist · 3-state ttl 8 RW lock + upgrade 7 If minute 35 arrives and lazy expiry is not working, skip the sweeper and describe it instead. A store that returns stale values is a failed round; a store without a sweeper is an honest trade-off.
The orange block is the one that cannot slip — lazy expiry inside get is the minimum viable version of this problem. Everything to the right of the green block is a bonus you narrate if you run out of time.

The follow-ups

  • “Support lists, sets and hashes, not just strings.”Entry.value becomes a sealed type (StringValue | ListValue | HashValue), commands validate the type and reject mismatches, and nothing else about the design changes — TTL, expiry, locking and eviction are all indifferent to what the value is. Say that last clause; it is the answer they are listening for.
  • “Notify me when a key expires.” → publish an event from the two places a key actually dies (the lazy delete and the sweeper) and let subscribers listen: Pub/Sub & Event-driven. Be honest that the notification fires when the key is reclaimed, not at the expiry instant — which is precisely the distinction the whole lesson is about.
  • “Make it distributed.” → consistent hashing spreads keys across nodes so each key lives on exactly one, and each node runs this same design locally. Your ReadWriteLock now guarantees nothing across the cluster, TTLs depend on clocks that drift between machines, and keys(pattern) becomes a fan-out to every node. Naming those three consequences is the answer.
  • “What if a very hot key expires and ten thousand requests miss at once?” → the thundering herd. One request rebuilds while the rest wait on the same future, or you refresh slightly before expiry. One sentence; it is a whole problem of its own.
  • “How would you test TTL?” → a FakeClock, and tests that read like sentences: set with ttl 5s, advance 4s, expect a hit; advance 2s more, expect a miss; assert size() was still 1 before the get and 0 after. Not a Thread.sleep in sight. This is the payoff for injecting the clock.
  • “What would you monitor?” → keys, expired-lazily, expired-actively, evicted, hit ratio, memory used against the limit, and the sweeper's sample-hit rate. That last one is the interesting one: if the sweeper keeps finding 25%+ expired every pass, it is losing the race and your TTLs are shorter than your reclaim rate.

How this round is lost

  • A Timer or ScheduledFuture per key. It looks like the most direct translation of “expire in 5 seconds” and it does not survive contact with a million keys. This is the single most common way to fail this problem.
  • Storing a countdown instead of an instant. Now something must decrement it, and you have invented the timer problem in a different shape.
  • Returning expired values. If get does not check expiresAt, the TTL is decorative. The store is wrong in the most basic possible way and no amount of sweeper sophistication saves it.
  • A sweeper that scans every key. O(n) on a loop, holding a lock, on a store meant to answer in microseconds. Sample instead.
  • synchronized on every method. Correct and slow, in a workload that is 95% reads — the one case where a read-write lock genuinely pays for itself.
  • ttl() returning -1 for both “no expiry” and “no such key”. Two very different facts, one indistinguishable answer, and a caller that cannot help but get it wrong.
  • Treating expiry and eviction as one mechanism. “When memory is full I delete the expired keys” is not an eviction policy — a store can be completely full of keys that are all perfectly alive.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

A live store with a virtual clock you control. Press ➕ SET a few times to add keys with TTLs, then ⏩ +2s — rows go grey and struck-through but stay in the table, and the size() stat still counts them, because nothing ran at the moment they expired. Click a struck-through row to GET it: it returns (nil) and only then vanishes — that is lazy expiry, done by hand, one key at a time. Press 😴 Cold key to create a key nobody will ever read and watch the leaked counter climb, then 🧹 Sweep to see the sampling loop draw 20 keys, delete the expired ones, and decide on screen whether to go round again. 🔒 SET no-TTL makes an existing key's expiry vanish, and 📉 Fill memory kills a key for space while everything else here dies of time.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Fill the store and read the columns

Press ➕ SET three times. Each row shows the key, the value, the expiresAt instant (an absolute number on the virtual clock, not a countdown) and a ttl that ticks down as a derived value. Note that user:7 arrives with expiresAt = ∞ — a key with no deadline is not a special case, it is just a very large number.

try 02

The money moment — advance the clock

Press ⏩ +2s until the first key's ttl hits zero. The row goes grey and struck through and its pill reads expired-but-present. Now look at the size() stat: it has not changed. Nothing ran when that expiry passed. The key is unreadable and still resident, and it will stay that way as long as you leave it alone.

try 03

Delete it by looking at it

Click the struck-through row to GET it. It returns (nil), the row disappears at that moment, size() finally drops and misses ticks up. You just performed lazy expiry by hand. Do it to a second key and notice that the store did no work at all on any key you did not touch.

try 04

Watch a cold key leak

Press 😴 Cold key. It writes a key nobody will ever read and jumps the clock past its TTL. The leaked counter starts climbing and nothing stops it — press ⏩ +2s a few more times and watch it keep climbing. This is the failure mode of a lazy-only store, and it is the entire reason the next button exists.

try 05

Run the sampling sweeper

Press 🧹 Sweep. It draws up to 20 keys that have a TTL, highlights exactly those, deletes the expired ones, and then shows the decision on screen: something like 6/20 expired → 30% > 25% → sampling again. It loops while the sample stays dense and stops when it goes sparse. The leaked keys vanish, and the cost was proportional to the garbage found — not to the size of the store.

try 06

Make a TTL disappear

Pick a row that still has a live ttl and press 🔒 SET no-TTL. The value updates and the expiresAt column flips to — the deadline is gone and the key is now permanent. This is a real production bug: a session refreshed with a plain set never logs the user out and never frees its memory. The fix is a keepTtl flag, and knowing to offer it is the point.

try 07

Kill a key for space instead of time

Press 📉 Fill memory until the memory bar crosses maxMemory. A key is evicted — and read the explain line carefully: this key was alive. It did not run out of time, it ran out of room. Two different mechanisms, two different counters, and mixing them up is one of the ways this round is lost.

try 08

Build it from memory

Blank file, in this order: Clock interface with a FakeClockEntry(value, expiresAtMillis) with an isExpired(now) → a map plus a get that checks expiresAt, deletes on expiry, and returns a miss → setWithTtl / expire / a three-state ttl → a sweepOnce() that samples 20 and repeats above 25%. Then a main() that sets a key with a 5s TTL, advances the fake clock by 6s, prints size() (still 1), calls get (nil), and prints size() again (0). If that last pair of numbers is not 1 then 0, you have not built this problem — you have built a hash map.

In practice

When to use it — and what trips people up

The shape you just learned

Strip the keys and values away and what is left is: state with a deadline, reclaimed lazily on access and swept probabilistically in the background. Once you have seen it here you start seeing it everywhere, and the same two questions apply every time — who checks the deadline? and who reclaims the ones nobody checks?

  • Session stores. A logged-in session is exactly this: a value with a sliding deadline. The only thing that logs a user out is a request arriving and finding the deadline passed.
  • Idempotency keys. “I have seen this request id before” is a set with a TTL. The lazy check happens on the next duplicate; the sweeper is what stops the set growing without limit.
  • Rate-limiter counters. A per-user counter with a window is a key with a TTL. There is no timer resetting anybody's quota — the next request notices the window rolled over.
  • DNS and HTTP caches. A record carries a TTL and the resolver checks it on lookup. Nothing purges your DNS cache at the instant a record expires.
  • Feature-flag and config caches. A value with a refresh deadline, checked on read. Same design, smaller vocabulary.
  • Lock leases. A distributed lock with a TTL is a key that dies on its own so a crashed holder cannot block everybody forever — and “expired but not yet reclaimed” is precisely the dangerous window you have to reason about.

The two-sentence version to say out loud

“A TTL is an absolute instant stored on the entry, so no timers exist. Reads check that instant and delete on the way past, and a background sampler catches the keys nobody reads — which means a key can be logically gone while still occupying memory, and that is a deliberate trade, not a bug.”

Where this design stops working

  • When expiry must be exact to the millisecond. Nothing here fires at the deadline. If a key expiring must trigger an action at that instant — a scheduled job, a billing event — you need a real timer wheel or a priority queue of deadlines, and that is a different design with a different cost.
  • When the store spans machines. Your ReadWriteLock protects one process. Across a cluster, TTLs depend on clocks that drift, keys(pattern) becomes a fan-out, and “delete if expired” becomes a distributed agreement problem.
  • When the data must not be lost. In-memory means gone on restart, and both durability mechanisms have a loss window you have to state as a number. If the answer must be zero, you are designing a database, not a cache.
  • When values get large and structured. The design does not change, but the costs do: eviction should count bytes rather than entries, and a single huge value can blow the memory bound on its own.
  • When the read/write mix flips. The read-write lock pays for itself only while reads dominate. A write-heavy store gets everything serialised anyway, and you should shard from the start — or reach for a lock-free map.

If you only remember one thing

Expired is not deleted. A key stops being readable at its expiry instant and stops being resident only when a reader or a sweeper touches it. Every design decision in this problem — the absolute instant, the lazy check, the sampling loop, the honest size() — exists because of the gap between those two moments.

What it gives you

  • Storing an absolute expiresAt costs eight bytes per key and zero running machinery — a million keys still means zero timers and zero background ticking.
  • Lazy expiry makes it impossible for a caller to read a stale value, and it costs one integer comparison on a lookup you were already doing.
  • The sampling sweeper bounds its own work: the CPU it spends tracks the amount of garbage it keeps finding, not the number of keys in the store.
  • A ReadWriteLock genuinely pays here, because in a plain key-value store a get really is a read — unlike a recency-ordered cache, where every get mutates.
  • Injecting a Clock turns every TTL test into three fast, deterministic lines instead of a suite full of Thread.sleep.

Common mistakes

  • Expired keys keep holding memory until something looks at them, so size() and real memory usage can disagree for an unbounded stretch of time.
  • The sweeper's guarantee is only probabilistic — roughly a quarter of the keys with a TTL may be expired-but-present at any instant, and there is no way to make it zero cheaply.
  • The lazy delete forces a read to become a write, and since read-write locks cannot upgrade in place, that path needs a release, a re-acquire and a re-check that are easy to get subtly wrong.
  • A plain set clearing the TTL is a genuine footgun — it matches Redis, and it has silently made session keys immortal in real systems.
  • keys(pattern) is O(n) and blocks every other operation while it runs, which is why production systems need a second, cursor-based API you have not built.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.*;
import java.util.concurrent.atomic.LongAdder;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/** Time behind an interface — the one seam that makes TTL testable without sleeping. */
interface Clock { long nowMillis(); }

final class SystemClock implements Clock {
    public long nowMillis() { return System.currentTimeMillis(); }
}

/** A clock you move by hand. Every TTL test in this file runs in microseconds because of it. */
final class FakeClock implements Clock {
    private long now;
    FakeClock(long startMillis) { this.now = startMillis; }
    public long nowMillis() { return now; }
    void advance(long millis) { now += millis; }
}

/**
 * Immutable value object. Holds an ABSOLUTE instant, never a countdown:
 * nothing has to tick it, and a million keys still means zero running timers.
 */
final class Entry {
    static final long NEVER = Long.MAX_VALUE;

    private final String value;
    private final long expiresAtMillis;

    Entry(String value, long expiresAtMillis) {
        this.value = Objects.requireNonNull(value);
        this.expiresAtMillis = expiresAtMillis;
    }

    String value()            { return value; }
    long expiresAtMillis()    { return expiresAtMillis; }
    boolean neverExpires()    { return expiresAtMillis == NEVER; }
    boolean isExpired(long now) { return now >= expiresAtMillis; }
}

/** Three genuinely different answers. A single int that means all three is a bad API. */
sealed interface TtlResult {
    record Remaining(long millis) implements TtlResult {
        public String toString() { return millis + "ms left"; }
    }
    record NoExpiry() implements TtlResult {
        public String toString() { return "no expiry"; }
    }
    record NoSuchKey() implements TtlResult {
        public String toString() { return "no such key"; }
    }
}

final class KeyValueStore {
    private static final int SAMPLE_SIZE = 20;
    private static final int MAX_SWEEP_ROUNDS = 16;   // so one pass ALWAYS terminates

    private final Map<String, Entry> map = new HashMap<>();

    // Only the keys that HAVE a deadline. The sweeper samples from here, never from
    // the whole keyspace — otherwise almost every draw is wasted on permanent keys.
    private final List<String> volatileKeys = new ArrayList<>();
    private final Map<String, Integer> volatileIndex = new HashMap<>();

    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private final Clock clock;
    private final int maxEntries;
    private final Random random = new Random(7);

    private final LongAdder hits = new LongAdder(), misses = new LongAdder();
    private final LongAdder lazyExpired = new LongAdder(), activeExpired = new LongAdder();
    private final LongAdder evicted = new LongAdder();

    KeyValueStore(Clock clock, int maxEntries) {
        this.clock = clock;
        this.maxEntries = maxEntries;
    }

    /* ------------------------------------------------------------------ reads */

    /**
     * A get really IS a read here — until it finds an expired key, and then it must WRITE.
     * Java's ReadWriteLock cannot upgrade in place, so we drop the read lock, take the
     * write lock, and RE-CHECK: another thread may have written a fresh value in the gap.
     */
    Optional<String> get(String key) {
        long now = clock.nowMillis();
        lock.readLock().lock();
        try {
            Entry e = map.get(key);
            if (e == null) { misses.increment(); return Optional.empty(); }
            if (!e.isExpired(now)) { hits.increment(); return Optional.of(e.value()); }
        } finally {
            lock.readLock().unlock();
        }

        lock.writeLock().lock();
        try {
            Entry again = map.get(key);
            if (again != null && again.isExpired(clock.nowMillis())) {
                removeLocked(key);
                lazyExpired.increment();
            }
            misses.increment();          // this call misses either way — it saw a dead key
            return Optional.empty();
        } finally {
            lock.writeLock().unlock();
        }
    }

    boolean exists(String key) { return get(key).isPresent(); }   // SAME expiry rule as get

    /** Three states, never overloaded onto one number. */
    TtlResult ttl(String key) {
        long now = clock.nowMillis();
        lock.readLock().lock();
        try {
            Entry e = map.get(key);
            if (e == null || e.isExpired(now)) return new TtlResult.NoSuchKey();
            if (e.neverExpires())               return new TtlResult.NoExpiry();
            return new TtlResult.Remaining(e.expiresAtMillis() - now);
        } finally {
            lock.readLock().unlock();
        }
    }

    /** The PHYSICAL entry count — expired-but-not-yet-reclaimed keys are included, on purpose. */
    int size() {
        lock.readLock().lock();
        try { return map.size(); } finally { lock.readLock().unlock(); }
    }

    /** What a caller could actually read right now. Usually smaller than size(). */
    int liveSize() {
        long now = clock.nowMillis();
        lock.readLock().lock();
        try {
            int n = 0;
            for (Entry e : map.values()) if (!e.isExpired(now)) n++;
            return n;
        } finally { lock.readLock().unlock(); }
    }

    /** O(n), and it holds the lock the whole way. This is exactly why SCAN with a cursor exists. */
    List<String> keys(String pattern) {
        long now = clock.nowMillis();
        lock.readLock().lock();
        try {
            List<String> out = new ArrayList<>();
            for (Map.Entry<String, Entry> e : map.entrySet())
                if (!e.getValue().isExpired(now) && glob(pattern, e.getKey())) out.add(e.getKey());
            Collections.sort(out);
            return out;
        } finally { lock.readLock().unlock(); }
    }

    /* ----------------------------------------------------------------- writes */

    /** Plain set CLEARS any existing deadline — the key becomes permanent. Real stores do this too. */
    void set(String key, String value) { set(key, value, false); }

    void set(String key, String value, boolean keepTtl) {
        lock.writeLock().lock();
        try {
            Entry old = map.get(key);
            long expiresAt = Entry.NEVER;
            if (keepTtl && old != null && !old.isExpired(clock.nowMillis()))
                expiresAt = old.expiresAtMillis();
            putLocked(key, new Entry(value, expiresAt));
            evictIfNeededLocked();
        } finally { lock.writeLock().unlock(); }
    }

    void setWithTtl(String key, String value, long ttlMillis) {
        lock.writeLock().lock();
        try {
            putLocked(key, new Entry(value, clock.nowMillis() + ttlMillis));   // ONE addition, once
            evictIfNeededLocked();
        } finally { lock.writeLock().unlock(); }
    }

    boolean delete(String key) {
        long now = clock.nowMillis();
        lock.writeLock().lock();
        try {
            Entry e = map.get(key);
            removeLocked(key);
            return e != null && !e.isExpired(now);       // do not claim to have deleted a ghost
        } finally { lock.writeLock().unlock(); }
    }

    boolean expire(String key, long ttlMillis) {
        lock.writeLock().lock();
        try {
            long now = clock.nowMillis();
            Entry e = map.get(key);
            if (e == null || e.isExpired(now)) return false;
            putLocked(key, new Entry(e.value(), now + ttlMillis));
            return true;
        } finally { lock.writeLock().unlock(); }
    }

    boolean persist(String key) {
        lock.writeLock().lock();
        try {
            long now = clock.nowMillis();
            Entry e = map.get(key);
            if (e == null || e.isExpired(now) || e.neverExpires()) return false;
            putLocked(key, new Entry(e.value(), Entry.NEVER));
            return true;
        } finally { lock.writeLock().unlock(); }
    }

    /* -------------------------------------------------- atomic read-modify-write */

    /** Two clients doing get-then-set would lose an increment. Doing it inside the store cannot. */
    long incr(String key, long delta) {
        lock.writeLock().lock();
        try {
            long now = clock.nowMillis();
            Entry e = map.get(key);
            boolean live = e != null && !e.isExpired(now);
            long current = live ? Long.parseLong(e.value()) : 0;      // missing key counts as 0
            long next = current + delta;
            putLocked(key, new Entry(Long.toString(next), live ? e.expiresAtMillis() : Entry.NEVER));
            return next;
        } finally { lock.writeLock().unlock(); }
    }

    /** Optimistic concurrency: write only if nobody changed it since you read it. */
    boolean compareAndSet(String key, String expected, String next) {
        lock.writeLock().lock();
        try {
            long now = clock.nowMillis();
            Entry e = map.get(key);
            if (e == null || e.isExpired(now) || !e.value().equals(expected)) return false;
            putLocked(key, new Entry(next, e.expiresAtMillis()));
            return true;
        } finally { lock.writeLock().unlock(); }
    }

    /* ---------------------------------------------------------- active expiry */

    /**
     * One pass of the sweeper. Draw 20 random keys that have a TTL, delete the dead ones,
     * and go again only while the sample stays dense. Bounded work, then it returns.
     */
    int sweepOnce() {
        int totalRemoved = 0;
        for (int round = 0; round < MAX_SWEEP_ROUNDS; round++) {
            List<String> sample;
            lock.readLock().lock();
            try { sample = sampleVolatileLocked(); } finally { lock.readLock().unlock(); }
            if (sample.isEmpty()) break;

            int expiredInSample = 0;
            lock.writeLock().lock();
            try {
                long now = clock.nowMillis();
                for (String key : sample) {
                    Entry e = map.get(key);
                    if (e != null && e.isExpired(now)) { removeLocked(key); expiredInSample++; }
                }
            } finally { lock.writeLock().unlock(); }

            totalRemoved += expiredInSample;
            activeExpired.add(expiredInSample);

            // more than 25% dead? go again immediately. Integer form of expired/n > 0.25
            if (expiredInSample * 4 <= sample.size()) break;
        }
        return totalRemoved;
    }

    private List<String> sampleVolatileLocked() {
        int n = Math.min(SAMPLE_SIZE, volatileKeys.size());
        List<String> out = new ArrayList<>(n);
        for (int i = 0; i < n; i++) out.add(volatileKeys.get(random.nextInt(volatileKeys.size())));
        return out;
    }

    /* ------------------------------------------------------------- eviction */

    /**
     * Dying of SPACE, not of time. Policy here is volatile-ttl: among keys that already
     * have a deadline, drop the one expiring soonest. The full machinery of allkeys-lru
     * is a problem of its own — this store only needs a pickVictim() seam.
     */
    private void evictIfNeededLocked() {
        while (map.size() > maxEntries) {
            String victim = null;
            long soonest = Long.MAX_VALUE;
            for (String key : volatileKeys) {
                Entry e = map.get(key);
                if (e != null && e.expiresAtMillis() < soonest) { soonest = e.expiresAtMillis(); victim = key; }
            }
            if (victim == null) victim = map.keySet().iterator().next();   // nothing volatile: random
            removeLocked(victim);
            evicted.increment();
        }
    }

    /* -------------------------------------------------------------- internals */

    private void putLocked(String key, Entry e) {
        map.put(key, e);
        if (e.neverExpires()) unmarkVolatile(key); else markVolatile(key);
    }

    private void removeLocked(String key) {
        map.remove(key);
        unmarkVolatile(key);
    }

    /** List + index, so a random pick and a removal are both O(1). */
    private void markVolatile(String key) {
        if (volatileIndex.containsKey(key)) return;
        volatileIndex.put(key, volatileKeys.size());
        volatileKeys.add(key);
    }

    private void unmarkVolatile(String key) {
        Integer i = volatileIndex.remove(key);
        if (i == null) return;
        int last = volatileKeys.size() - 1;
        if (i != last) {
            String moved = volatileKeys.get(last);
            volatileKeys.set(i, moved);
            volatileIndex.put(moved, i);
        }
        volatileKeys.remove(last);
    }

    /** One-star glob, which is all keys(pattern) ever really needs in an interview. */
    private static boolean glob(String pattern, String key) {
        int star = pattern.indexOf('*');
        if (star < 0) return pattern.equals(key);
        String head = pattern.substring(0, star), tail = pattern.substring(star + 1);
        return key.length() >= head.length() + tail.length()
            && key.startsWith(head) && key.endsWith(tail);
    }

    String stats() {
        return "keys=" + size() + " live=" + liveSize()
             + " hits=" + hits.sum() + " misses=" + misses.sum()
             + " expired-lazily=" + lazyExpired.sum()
             + " expired-actively=" + activeExpired.sum()
             + " evicted=" + evicted.sum();
    }
}

public class Main {
    public static void main(String[] args) {
        FakeClock clock = new FakeClock(0);
        KeyValueStore store = new KeyValueStore(clock, 100);

        store.setWithTtl("session:1", "abc", 5_000);
        store.set("user:7", "nina");                       // no deadline — permanent
        System.out.println("t=0     get(session:1) = " + store.get("session:1").orElse("(nil)"));
        System.out.println("t=0     size()=" + store.size() + " live=" + store.liveSize());

        clock.advance(6_000);                              // one second past the deadline
        System.out.println();
        System.out.println("t=6000  the key expired 1s ago. NOTHING RAN.");
        System.out.println("t=6000  size()=" + store.size() + " live=" + store.liveSize() + "   <- still counted");
        System.out.println("t=6000  get(session:1) = " + store.get("session:1").orElse("(nil)"));
        System.out.println("t=6000  size()=" + store.size() + "   <- the GET removed it (lazy expiry)");

        store.setWithTtl("report:2019", "...", 1_000);     // a key nobody will ever read
        clock.advance(60_000);
        System.out.println();
        System.out.println("t=66000 cold key expired 59s ago, never read: size()=" + store.size());
        System.out.println("t=66000 sweeper reclaimed " + store.sweepOnce() + " key(s), size()=" + store.size());

        store.setWithTtl("cart:42", "[a]", 30_000);
        System.out.println();
        System.out.println("ttl(cart:42) = " + store.ttl("cart:42"));
        System.out.println("ttl(user:7)  = " + store.ttl("user:7"));
        System.out.println("ttl(nope)    = " + store.ttl("nope"));

        store.set("cart:42", "[a,b]");                     // plain set — the TTL is gone
        System.out.println("after a plain set,   ttl(cart:42) = " + store.ttl("cart:42"));
        store.expire("cart:42", 30_000);
        store.set("cart:42", "[a,b,c]", true);             // keepTtl
        System.out.println("with keepTtl=true,   ttl(cart:42) = " + store.ttl("cart:42"));

        System.out.println();
        System.out.println("incr(visits) x3      = " + store.incr("visits", 1) + ", "
                                                     + store.incr("visits", 1) + ", "
                                                     + store.incr("visits", 1));
        System.out.println("cas(visits, 3 -> 10) = " + store.compareAndSet("visits", "3", "10"));
        System.out.println("cas(visits, 3 -> 99) = " + store.compareAndSet("visits", "3", "99") + "  (stale)");

        System.out.println();
        System.out.println("keys(\"c*\")          = " + store.keys("c*"));
        System.out.println(store.stats());
    }
}

/* -------------------------- expected output --------------------------
t=0     get(session:1) = abc
t=0     size()=2 live=2

t=6000  the key expired 1s ago. NOTHING RAN.
t=6000  size()=2 live=1   <- still counted
t=6000  get(session:1) = (nil)
t=6000  size()=1   <- the GET removed it (lazy expiry)

t=66000 cold key expired 59s ago, never read: size()=2
t=66000 sweeper reclaimed 1 key(s), size()=1

ttl(cart:42) = 30000ms left
ttl(user:7)  = no expiry
ttl(nope)    = no such key
after a plain set,   ttl(cart:42) = no expiry
with keepTtl=true,   ttl(cart:42) = 30000ms left

incr(visits) x3      = 1, 2, 3
cas(visits, 3 -> 10) = true
cas(visits, 3 -> 99) = false  (stale)

keys("c*")          = [cart:42]
keys=3 live=3 hits=1 misses=1 expired-lazily=1 expired-actively=1 evicted=0
--------------------------------------------------------------------- */

References & further reading

7 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 08

How should a key's TTL be stored?

question 02 / 08

A key is set at t=0 with a 5-second TTL. At t=8 nobody has touched it. What does size() return?

question 03 / 08

What is the specific failure mode of a store that only does lazy expiry?

question 04 / 08

Why does a background expirer sample around 20 random keys instead of scanning them all?

question 05 / 08

What must ttl(key) be able to express?

question 06 / 08

A key has 20 minutes left on its TTL. A caller does a plain set(key, newValue). What happens to the deadline?

question 07 / 08

Why does a ReadWriteLock help in a plain key-value store, when it does not help in an LRU cache — and what is the catch?

question 08 / 08

The store hits its memory limit. Every key in it is alive and none has a TTL. What should happen?

0/8 answered