In-memory caches and data stores
"Put Redis in front of it" is the most common answer in system design interviews, and often the right one. But Redis now has a fork, Valkey, under a different licence; Memcached is still the simplest choice for plain caching; every cloud sells its own managed version; and Azure is retiring one of its Redis services. This guide explains how each option works, how they differ, and which to choose, with a chooser and code you can run.
- Sam Is "put Redis in front of it" still the answer?
- Priya Often, but there's more to choose now. Redis has a fork, Valkey, under a different licence. Memcached is still the simplest for plain caching. Every cloud sells its own managed version. And Azure is retiring one of its Redis services.
- Sam Where can a cache live?
- Priya In each app server's memory, which is fastest but leaves every server with its own copy. Or in a shared cache all servers use, where one invalidation reaches everyone, at the cost of a network hop.
- Sam Walk me through a request.
- Priya A cold miss goes from the local cache to the shared cache to the database, and fills both caches on the way back. The next server hits the shared cache. When the price changes, the app updates the database and deletes the key. But another server's local copy stays stale until its time to live expires.
- Sam What are the options?
- Priya Redis, with rich data structures; since Redis eight it's licensed under RSAL, SSPL or AGPL. Valkey, the BSD-licensed fork backed by the Linux Foundation, which keeps the same commands. Memcached, multi-threaded and strings only. Dragonfly, multi-threaded, under the Business Source License. And the managed services: Memorystore on Google Cloud, ElastiCache on AWS, and Azure Managed Redis, which replaces Azure Cache for Redis. Its tiers retire in March twenty twenty-seven and September twenty twenty-eight.
- Sam How do I choose?
- Priya Must every server see updates at once? If not, a local cache may do. Only caching, or data structures too? Plain caching suits Memcached; sorted sets and counters point to Redis or Valkey. On a cloud, use the managed service, preferring Valkey where offered. And if losing the data would lose information, it belongs in a database, or behind a durable option.
- Priya Your turn. Two servers have local caches, and server A updates a price. What does server B serve?
- Priya The old price, until B's copy expires. That's why shared caches or short TTLs matter.
- Sam And the code?
- Priya A small LRU cache with a time to live, used as local caches and as a shared cache, so you can see the stale copy and the eviction order.
- Sam Common mistakes?
- Priya Local caches with no plan for staleness. Treating the cache as the source of truth. Ignoring licences when self-hosting. Building on a retiring service. And running slow commands that block a single-threaded store.
1. What it is
An in-memory store keeps data in RAM, so a read takes well under a millisecond instead of the several milliseconds a database query costs. Used as a cache, it holds copies of data that live elsewhere; used as a data store, it holds data of its own (sessions, rate-limit counters, leaderboards, queues). Module 09 teaches the caching patterns, invalidation and eviction; this guide is about the products.
| Layer | Where it lives | Strength | Weakness |
|---|---|---|---|
| In-process cache | Inside each app server's memory | Fastest: no network hop | Every server has its own copy, so updates leave stale copies elsewhere |
| Shared cache | A separate cache service all servers use | One copy, so one invalidation reaches everyone | A network hop, and one more system to run |
2. How it works
One request for a product price, through two cache layers and a database.
3. The products
Redis
A single thread executes commands against rich data structures (strings, hashes, lists, sets, sorted sets, streams), with I/O threads helping with network work. Persistence by snapshots (RDB) and an append-only file; replication, and Redis Cluster for sharding. Since Redis 8, Redis Open Source is licensed under your choice of RSALv2, SSPLv1 or AGPLv3.
Best for: caching plus data structures (leaderboards, rate limits, queues), when its licence suits you.
Valkey
A fork of Redis created in 2024 after Redis changed its licence, now backed by the Linux Foundation under the BSD licence. It keeps Redis's commands and protocol, so Redis clients work unchanged. The latest release is 9.1.3 (October 2026). The managed services on AWS and Google Cloud offer it alongside or instead of Redis.
Best for: Redis-style workloads where a permissive open-source licence matters.
Memcached
A multi-threaded key-value cache for strings only, with a slab allocator and no persistence or replication. Clients spread keys across servers with consistent hashing.
Best for: plain, large, simple caches where data structures and durability don't matter.
Dragonfly
A multi-threaded in-memory store that speaks the Redis and Memcached protocols, aiming for more throughput per machine. Released under the Business Source License 1.1, which isn't an open-source licence.
Best for: very high throughput on a single large machine, if the licence fits.
Caffeine, Ristretto and similar
Caches inside your application (Caffeine for the JVM, Ristretto for Go), using admission policies such as TinyLFU to keep the most useful entries.
Best for: hot, rarely changing data such as configuration, or a small first layer in front of a shared cache.
Memorystore
Google Cloud's managed in-memory service, offered as Memorystore for Valkey, Memorystore for Redis Cluster and Memorystore for Redis. Memorystore for Memcached is marked deprecated.
Best for: Redis or Valkey on Google Cloud without running servers; choose Valkey or Redis Cluster to scale out.
Amazon ElastiCache
Managed Valkey, Redis OSS and Memcached, either serverless (capacity scales automatically) or as node-based clusters you size yourself. Node-based Valkey clusters can enable durability, persisting data in a multi-AZ transaction log so it survives every node failing.
Best for: caching on AWS; serverless when traffic is unpredictable.
Azure Managed Redis (and Azure Cache for Redis)
Microsoft is retiring Azure Cache for Redis: the Enterprise tiers on 31 March 2027 and the Basic, Standard and Premium tiers on 30 September 2028. Azure Managed Redis is the replacement, with migration tooling in preview since April 2026.
Best for: new Redis workloads on Azure, on Azure Managed Redis.
4. Differences at a glance
| Option | Data model | Threading | Persistence | Scaling out | Licence |
|---|---|---|---|---|---|
| Redis | Rich data structures | One command thread, I/O threads | RDB snapshots and AOF | Redis Cluster (hash slots) | RSALv2, SSPLv1 or AGPLv3 (Redis 8+) |
| Valkey | Same as Redis | Same model | RDB and AOF | Cluster mode | BSD |
| Memcached | Strings only | Multi-threaded | None | Client-side sharding | BSD |
| Dragonfly | Redis and Memcached APIs | Multi-threaded | Snapshots | Mainly vertical | BSL 1.1 |
| In-process | Objects in your language | Your app's | None | One copy per server | Library's own |
| Memorystore | Valkey or Redis | Managed | Engine and tier dependent | Valkey and Redis Cluster | Managed service |
| ElastiCache | Valkey, Redis OSS or Memcached | Managed | Optional durability (node-based Valkey) | Serverless, or cluster mode | Managed service |
| Azure Managed Redis | Redis | Managed | Tier dependent | Clustered | Managed service |
5. When to choose which, and why
- Do the servers need to agree? If a stale copy for a few seconds is fine (configuration, reference data), an in-process cache is fastest. If an update must be seen everywhere at once, use a shared cache.
- Only caching, or data structures too? Plain key-value caching works well on Memcached. Sorted sets, counters with expiry, streams and pub/sub point to Redis or Valkey.
- Licence: if you run it yourself and need an OSI open-source licence without copyleft obligations, Valkey (BSD). Redis 8+ offers AGPLv3 among its options; check what it means for your use.
- On a cloud, use its managed service: Memorystore, ElastiCache or Azure Managed Redis. Prefer Valkey where offered unless you need a Redis-only feature.
- Must the data survive a restart? A cache by definition can be rebuilt. If losing it loses information, either enable a durable option (such as ElastiCache's durability for Valkey) or keep it in a database.
Try it: choose for your requirements
6. Local vs shared caches, in code
A small LRU cache with a time to live, used two ways. With a local cache in each app server, server B keeps serving the old price until its TTL expires, because server A's invalidation only cleared A's own copy. With one shared cache, a single invalidation reaches everyone. The last line shows least-recently-used eviction at work. The program was run in all three languages; each prints the output below it.
from collections import OrderedDict
class LruCache:
"""An LRU cache with a time to live, like a Redis/Valkey key with EXPIRE, or a local in-process cache."""
def __init__(self, capacity, ttl):
self.items, self.capacity, self.ttl = OrderedDict(), capacity, ttl
self.hits = self.misses = self.evictions = 0
def get(self, key, now):
item = self.items.get(key)
if item and item[1] > now:
self.items.move_to_end(key) # most recently used
self.hits += 1
return item[0]
self.misses += 1
return None
def set(self, key, value, now):
self.items[key] = (value, now + self.ttl)
self.items.move_to_end(key)
if len(self.items) > self.capacity:
self.items.popitem(last=False) # evict the least recently used
self.evictions += 1
def delete(self, key):
self.items.pop(key, None)
DB = {"price:42": 100}
def read(cache, key, now):
"""Cache-aside: try the cache, fall back to the database, then fill the cache."""
value = cache.get(key, now)
if value is None:
value = DB[key]
cache.set(key, value, now)
return value
# Two app servers with their own local caches, against one shared cache (Redis, Valkey, Memorystore...).
local_a, local_b, shared = LruCache(3, ttl=60), LruCache(3, ttl=60), LruCache(3, ttl=60)
for c in (local_a, local_b, shared):
read(c, "price:42", now=0)
DB["price:42"] = 90 # server A updates the price at t=10 and invalidates its cache
local_a.delete("price:42")
shared.delete("price:42")
print("t=20 local caches: A sees", read(local_a, "price:42", 20), "| B sees", read(local_b, "price:42", 20), "(stale until its TTL)")
print("t=20 shared cache: A sees", read(shared, "price:42", 20), "| B sees", read(shared, "price:42", 20))
print("t=61 local cache B sees", read(local_b, "price:42", 61), "(TTL expired, refetched)")
lru = LruCache(3, ttl=1000)
for k in ["a", "b", "c", "a", "d", "b", "e", "a"]:
if lru.get(k, 0) is None:
lru.set(k, k.upper(), 0)
print(f"LRU, capacity 3: {lru.hits} hits, {lru.misses} misses, {lru.evictions} evictions; kept {', '.join(lru.items)}")
class LruCache {
// An LRU cache with a time to live, like a Redis/Valkey key with EXPIRE, or a local in-process cache.
items = new Map<string, [number | string, number]>(); // Map keeps insertion order: oldest first
hits = 0; misses = 0; evictions = 0;
constructor(private capacity: number, private ttl: number) {}
get(key: string, now: number) {
const item = this.items.get(key);
if (item && item[1] > now) {
this.items.delete(key); this.items.set(key, item); // most recently used
this.hits++;
return item[0];
}
this.misses++;
return undefined;
}
set(key: string, value: number | string, now: number) {
this.items.delete(key);
this.items.set(key, [value, now + this.ttl]);
if (this.items.size > this.capacity) {
this.items.delete(this.items.keys().next().value!); // evict the least recently used
this.evictions++;
}
}
delete(key: string) { this.items.delete(key); }
}
const DB: Record<string, number> = { "price:42": 100 };
function read(cache: LruCache, key: string, now: number) {
// Cache-aside: try the cache, fall back to the database, then fill the cache.
let value = cache.get(key, now);
if (value === undefined) { value = DB[key]; cache.set(key, value, now); }
return value;
}
// Two app servers with their own local caches, against one shared cache (Redis, Valkey, Memorystore...).
const localA = new LruCache(3, 60), localB = new LruCache(3, 60), shared = new LruCache(3, 60);
for (const c of [localA, localB, shared]) read(c, "price:42", 0);
DB["price:42"] = 90; // server A updates the price at t=10 and invalidates its cache
localA.delete("price:42");
shared.delete("price:42");
console.log(`t=20 local caches: A sees ${read(localA, "price:42", 20)} | B sees ${read(localB, "price:42", 20)} (stale until its TTL)`);
console.log(`t=20 shared cache: A sees ${read(shared, "price:42", 20)} | B sees ${read(shared, "price:42", 20)}`);
console.log(`t=61 local cache B sees ${read(localB, "price:42", 61)} (TTL expired, refetched)`);
const lru = new LruCache(3, 1000);
for (const k of ["a", "b", "c", "a", "d", "b", "e", "a"]) if (lru.get(k, 0) === undefined) lru.set(k, k.toUpperCase(), 0);
console.log(`LRU, capacity 3: ${lru.hits} hits, ${lru.misses} misses, ${lru.evictions} evictions; kept ${[...lru.items.keys()].join(", ")}`);import java.util.*;
public class Main {
static class LruCache {
// An LRU cache with a time to live, like a Redis/Valkey key with EXPIRE, or a local in-process cache.
record Item(Object value, int expires) {}
final LinkedHashMap<String, Item> items = new LinkedHashMap<>(16, 0.75f, true); // access order: oldest first
final int capacity, ttl;
int hits, misses, evictions;
LruCache(int capacity, int ttl) { this.capacity = capacity; this.ttl = ttl; }
Object get(String key, int now) {
Item item = items.get(key); // a get also marks it most recently used
if (item != null && item.expires() > now) { hits++; return item.value(); }
misses++;
return null;
}
void set(String key, Object value, int now) {
items.put(key, new Item(value, now + ttl));
if (items.size() > capacity) {
items.remove(items.keySet().iterator().next()); // evict the least recently used
evictions++;
}
}
void delete(String key) { items.remove(key); }
}
static final Map<String, Integer> DB = new HashMap<>(Map.of("price:42", 100));
static Object read(LruCache cache, String key, int now) {
// Cache-aside: try the cache, fall back to the database, then fill the cache.
Object value = cache.get(key, now);
if (value == null) { value = DB.get(key); cache.set(key, value, now); }
return value;
}
public static void main(String[] args) {
// Two app servers with their own local caches, against one shared cache (Redis, Valkey, Memorystore...).
LruCache localA = new LruCache(3, 60), localB = new LruCache(3, 60), shared = new LruCache(3, 60);
for (LruCache c : List.of(localA, localB, shared)) read(c, "price:42", 0);
DB.put("price:42", 90); // server A updates the price at t=10 and invalidates its cache
localA.delete("price:42");
shared.delete("price:42");
System.out.println("t=20 local caches: A sees " + read(localA, "price:42", 20) + " | B sees " + read(localB, "price:42", 20) + " (stale until its TTL)");
System.out.println("t=20 shared cache: A sees " + read(shared, "price:42", 20) + " | B sees " + read(shared, "price:42", 20));
System.out.println("t=61 local cache B sees " + read(localB, "price:42", 61) + " (TTL expired, refetched)");
LruCache lru = new LruCache(3, 1000);
for (String k : List.of("a", "b", "c", "a", "d", "b", "e", "a")) if (lru.get(k, 0) == null) lru.set(k, k.toUpperCase(), 0);
System.out.println("LRU, capacity 3: " + lru.hits + " hits, " + lru.misses + " misses, " + lru.evictions + " evictions; kept " + String.join(", ", lru.items.keySet()));
}
}t=20 local caches: A sees 90 | B sees 100 (stale until its TTL)
t=20 shared cache: A sees 90 | B sees 90
t=61 local cache B sees 90 (TTL expired, refetched)
LRU, capacity 3: 1 hits, 7 misses, 4 evictions; kept b, e, a
7. Say it in the interview
- "Redis or Memcached?" Memcached for simple, large key-value caching with multi-threaded throughput; Redis or Valkey when I need data structures, expiry on counters, persistence or replication. On a cloud I'd use the managed service, preferring Valkey where it's offered.
- "Local or distributed cache?" Local for hot, rarely changing data where a short stale window is fine; a shared cache when an update must be seen everywhere. Often both: a small local layer in front of a shared one.
- "Can you use the cache as the database?" Only if the data can be rebuilt, or with a durable option enabled. Otherwise the database stays the source of truth.
8. Common mistakes
9. Check yourself
Two app servers each keep a local cache. Server A updates a price and clears its cache. What does server B serve?
You need a leaderboard with sorted scores and expiring counters. Which fits?
Why might a team choose Valkey over Redis for self-hosting?
Starting a new Redis workload on Azure today, which service?
10. Sources and further reading
Product facts checked on 4 November 2026. Licences, versions and retirement dates change; check before relying on them.
| Source | Used for |
|---|---|
| Redis licences | RSALv2, SSPLv1 or AGPLv3 from Redis 8 |
| Valkey | BSD licence, Linux Foundation, latest release |
| Memorystore documentation | Memorystore for Valkey, Redis Cluster and Redis; Memcached deprecated |
| What is Amazon ElastiCache? | Engines, serverless and node-based clusters, durability |
| Azure Cache for Redis retirement FAQ | Retirement dates and Azure Managed Redis |
| Dragonfly licence | BSL 1.1 |
| Module 09: Caching | Patterns, invalidation, eviction, stampedes, hot keys |