What Is Distributed Caching?

Distributed caching is a technique in which frequently accessed data is held in memory across several servers, and shared by every instance of an application, rather than kept inside each instance separately. A distributed cache is the store that results: one logical cache, physically spread across many nodes, reachable over the network by any client that needs it.

The word doing the work in that definition is shared. A cache is fast because it keeps data in RAM instead of on disk, and that part is true of any cache. What makes a cache distributed is that it lives outside your application processes, so ten application instances see one copy of the data instead of ten copies that drift apart.

Distributed caching describes where the cache lives. It does not describe how your application talks to it — that is the caching pattern, most commonly cache-aside. The two questions are independent, and mixing them up is the source of a lot of confused architecture discussions.

Local Cache vs Distributed Cache

A local cache — also called an in-process, embedded, or in-memory cache — lives inside the application's own heap. A ConcurrentHashMap, a Caffeine cache, or Ehcache running in embedded mode are all local caches. Reads are extremely fast because they never leave the JVM. A distributed cache lives in a separate process, usually on separate machines, and every read costs a network round trip.

That round trip is the whole trade. You pay microseconds-to-milliseconds of network latency in exchange for a single, shared, consistent view of the data.

Local cacheDistributed cache
Where it livesInside the application process, on the heapA separate cluster of cache servers
Read latencyNanoseconds — a memory lookupSub-millisecond to a few milliseconds — a network hop plus deserialization
Consistency across instancesNone. Each instance has its own copy and they divergeOne copy. Every instance reads the same value, provided reads go to the primary
CapacityBounded by one JVM heap, and it competes with your application for that heapBounded by the cluster; add nodes to add memory
Survives a restart or deployNo. The cache dies with the process and rebuilds coldYes. The cache outlives any individual application instance
Effect on GCLarge caches mean large heaps and longer pausesIndirect. The data is not on your heap, but every read allocates while deserializing
Serialization costNone. Objects are stored as referencesEvery read and write serializes and deserializes
Failure blast radiusContained. One instance loses its cacheShared. A cache outage affects every instance at once
Operational costNone. It is a libraryA cluster to deploy, monitor, secure, and upgrade

Read that table as a decision, not a scoreboard. A local cache wins on latency and simplicity and loses on correctness the moment you run more than one instance. A distributed cache wins on correctness and capacity and costs you a network hop plus a piece of infrastructure to run.

Which means the honest rule is narrow: use a local cache when the data is immutable or when stale reads are genuinely harmless — reference tables, feature flags refreshed on a timer, computed values that are cheap to be wrong about. Use a distributed cache when a stale read is a bug. And when you want both, see near caching below, which is not a compromise between the two so much as a way to run them in layers.

Why a Local Cache Breaks in a Distributed System

The failure is easy to describe and easy to miss in testing, because it does not appear until you run a second instance.

Three application servers sit behind a load balancer, each with a local cache holding a product price of $10. A request to update the price to $12 lands on instance A. Instance A writes to the database and updates its own cache. Instances B and C know nothing about it. They keep serving $10 — not for a moment, but until their entries happen to expire.

Local cache per instance Distributed cache App A App B App C local cache price = 12 local cache price = 10 local cache price = 10 three copies, three answers only App A saw the update App A App B App C shared distributed cache price = 12 one copy, one answer every instance reads the same value

Four consequences follow, and they compound:

  • Divergent reads. The same request returns different answers depending on which instance serves it. Users refresh and watch the price flicker. Reproducing the bug is miserable, because it depends on load-balancer routing.
  • Sticky sessions as a workaround. The usual fix is to pin each user to one instance, which makes the tier stateful, breaks even load distribution, and turns a single instance restart into a visible outage for the users pinned to it.
  • Cold start on every deploy. Roll the fleet and every cache goes with it. The database absorbs the full read load of a warming cache, at the exact moment you have just changed something — see the thundering herd problem, and cache warming for how to fill a cold cache without causing a second one.
  • Memory multiplied by instance count. A 2 GB working set cached locally across twelve instances is 24 GB of RAM holding twelve copies of the same data, each contributing to that instance's garbage collection pauses.

None of this is an argument that local caches are bad. It is an argument that a local cache is a per-instance optimization, not a system-wide source of truth — and that treating it as the latter is what breaks.

How Distributed Caching Works

A distributed cache has to answer one question that a single-node cache never faces: given a key, which node holds it? Every design decision follows from that.

Partitioning. The keyspace is divided across nodes so each holds a slice. The naive approach is hash(key) % node_count, which works until the node count changes — then almost every key maps somewhere new and the entire cache is effectively invalidated at once. Consistent hashing solves this by mapping both keys and nodes onto a ring, so adding or removing a node only remaps the keys adjacent to it. Redis Cluster solves the same problem a different way — its specification is explicit that it does not use consistent hashing. It defines a fixed 16,384 hash slots, assigns them to nodes, and derives each key's slot as CRC16(key) mod 16384 — or from just the substring inside {...} when the key carries a hash tag, which is how related keys are pinned to one slot so multi-key operations still work. Slots move between nodes during resharding; keys never rehash.

Replication. Partitioning alone means losing a node loses its slice. Replicas hold copies so a failed node can be replaced by a promoted replica. Note that in Redis and Valkey this replication is asynchronous — a write is acknowledged before the replica confirms it, so a failover can drop recently acknowledged writes. For a cache, that is usually an acceptable trade; the authoritative copy is in the database. It stops being acceptable the moment you use the cache as a lock or a counter of record. See split-brain for what that failure looks like in detail.

Eviction and expiry. A cache is a bounded space, so something must decide what leaves. TTLs remove entries on a timer; eviction policies remove entries under memory pressure. These are different mechanisms and both matter — see Redis TTL for expiry semantics.

Client-side routing. In most modern distributed caches the client, not a proxy, knows the topology. It maintains a slot-to-node map, sends each command directly to the node that owns the key, and refreshes its map on redirection. This keeps the hop count at one — but it means your client library is doing real distributed-systems work, and its quality matters more than teams usually expect.

Distributed Cache Architecture

Two broad shapes exist, and the choice affects far more than performance.

Client-server (remote) architecture. The cache runs as its own tier of dedicated servers. Applications connect over the network as clients. Redis, Valkey, and Memcached all work this way. The cache scales independently of the application, survives application restarts, and is shared by services written in different languages. This is the dominant model, and it is what most people mean by a distributed in-memory cache.

Embedded (in-process) architecture. The cache runs inside the application processes themselves, which cluster with each other and pool their heaps. This is the in-memory data grid model — Hazelcast, Apache Ignite, and Oracle Coherence. (Ehcache is often grouped here, but its clustered mode depends on an external Terracotta server, which makes it client-server in the sense above.) Reads of locally owned data need no network hop, but the cache's lifecycle is welded to the application's: scaling the app rebalances the cache, and a deploy triggers data movement. Our in-memory data grid comparison covers the trade-offs, and the migration guides for Ehcache and Hazelcast cover moving off it.

Within the client-server model, the topology matters too. A single node is simple and is a single point of failure. Sentinel adds automatic failover without partitioning the data — capacity stays bounded by one machine's memory. Cluster partitions across many masters, each with replicas, and is what a distributed cache architecture looks like at scale. The Sentinel vs Cluster comparison covers the decision.

Near Cache: Using Both Layers

The local-versus-distributed framing implies a choice. In practice you can have both, and for read-heavy workloads you usually should.

A near cache puts a small local cache in front of the distributed one. Reads check local memory first and only cross the network on a miss. What makes it more than a plain local cache is the invalidation channel: when an entry changes, every instance holding a copy is told to drop or replace it. The divergence problem from the earlier example is handled by the cache layer rather than by your code.

Two mechanisms are in use, and the difference matters more than it first looks. Under CLIENT TRACKING, the server records which clients have read which keys and pushes an invalidation to exactly those clients — point-to-point, not a broadcast. This is what Jedis and Lettuce use for client-side caching, and it works well for whole objects. Its limitation is that the invalidation message carries only the object name: for a hash backing a cached Map, there is no way to say which field changed, so the whole Map has to be dropped.

The alternative is to carry that detail yourself. Redisson's RLocalCachedMap publishes a hash of the changed key on a per-map pub/sub topic, so other instances evict the single affected entry instead of the entire structure — which is why it, rather than the tracking protocol, is the default for Redisson's local caches. Redisson exposes the tracking-based approach separately as RClientSideCaching, which requires RESP3. Redis client-side caching covers the protocol-level detail.

The costs are real and worth stating plainly. There is a staleness window between a write and the arrival of the invalidation message, so a near cache is not strictly consistent. Write-heavy keys generate invalidation traffic that can cost more than the reads it saves. And you are back to spending application heap. A near cache is an optimization for data that is read far more often than it is written, which describes most caches but not all of them.

Distributed Caching Patterns

Where the cache lives is one question; how your application interacts with it is another. Four patterns cover almost everything:

PatternWho loads the dataBest for
Cache-asideThe application checks the cache, falls back to the database, and populates the cache itselfThe default. Read-heavy workloads that tolerate a cold start
Read-throughThe cache invokes a loader on a miss; the application only ever talks to the cacheKeeping data-access logic in one place
Write-throughWrites go to the cache, which synchronously writes to the databaseConsistency between cache and database on the write path
Write-behindWrites go to the cache and are flushed to the database asynchronouslyWrite-heavy workloads that can absorb a durability risk

These are orthogonal to the local/distributed decision — you can run cache-aside against a local cache or write-behind against a cluster. Our guide to Java caching strategies works through each with code.

Keeping the Cache and the Database in Sync

A distributed cache removes the problem of instances disagreeing with each other. It does not remove the problem of the cache disagreeing with the database. That is cache invalidation, and it is genuinely hard.

Three points are worth internalizing:

  • Delete, don't update, on a write. Writing the new value into the cache races with concurrent writers and can leave the older value permanently resident. Deleting the entry means the next reader reloads from the database — one extra miss in exchange for removing a class of bug.
  • A TTL is a correctness backstop, not a strategy. Explicit invalidation will eventually be missed — a batch job, a database trigger, a manual fix. A TTL bounds how long any such miss can persist. Set one on everything, even entries you invalidate explicitly.
  • Simultaneous expiry is its own failure. Entries created together expire together and stampede the database in unison. Add jitter to TTLs, and consider having one caller rebuild while others wait on a lock. See thundering herd.

Using Redis and Valkey as a Distributed Cache

Redis and Valkey are the default answer when teams go looking for a Redis distributed cache, and the reasons are worth being specific about — "it's fast" is true of every cache. A distributed Redis cache is typically deployed as Redis Cluster or Valkey Cluster once it outgrows a single node, with the client routing each key to the node that owns its slot.

Data structures, not just strings. A cache that only stores opaque blobs forces read-modify-write cycles through your application for anything structured. Redis and Valkey store hashes, lists, sets, and sorted sets natively, so you can update one field of a cached object, or maintain a cached leaderboard, without moving the whole value across the network. This is the main structural difference from Memcached — see Redis vs Memcached.

Eviction policy is a deliberate choice. maxmemory defaults to 0 — no limit — and maxmemory-policy defaults to noeviction. Left alone, the instance simply grows until the operating system intervenes. Set a limit without changing the policy and the commands that allocate — SET, INCR, HSET, LPUSH — start returning OOM command not allowed instead of making room, while reads and non-allocating writes like DEL keep working. That is correct behaviour for a datastore and wrong for a cache, so set both explicitly:

maxmemory 4gb
maxmemory-policy allkeys-lru

allkeys-lru evicts the least recently used key regardless of TTL; allkeys-lfu evicts the least frequently used, which handles workloads with a stable hot set better; the volatile-* variants only consider keys that have a TTL set, and fall back to noeviction behaviour if none is eligible. Redis eviction policy covers the full set.

What Redis does not give you. Being clear about this is more useful than a feature list. Redis is a key-value store with expiry — it has no notion of a cache pattern, no read-through loader, no write-behind queue, and no near cache of its own. It supplies the raw invalidation signal through CLIENT TRACKING, but the local cache that consumes it, and every pattern above, lives in the client library. Which is why, on the JVM, the choice of client determines what your distributed cache can actually do.

Java Distributed Cache With Redisson

Redisson is a Java client for Redis and Valkey that exposes the store through the java.util interfaces you already use, so a Java distributed cache reads as a Map rather than as a sequence of commands. This is the practical difference between clients: a lower-level client gives you commands, and you build the cache; Redisson gives you the cache.

What follows is the shape of the API, not a tutorial — for a full walkthrough with runnable examples, benchmarks and a Spring Boot project, see Distributed Caching in Java with Valkey and Redis.

The baseline is RMapCache, which adds per-entry TTL and maximum idle time to the Map interface:

RMapCache<String, Product> cache = redisson.getMapCache("products");

// TTL of 10 minutes
cache.put(id, product, 10, TimeUnit.MINUTES);

// TTL of 30 minutes, plus eviction after 5 minutes idle
cache.put(id, product, 30, TimeUnit.MINUTES, 5, TimeUnit.MINUTES);

RMapCache implements expiry itself, with a background task that sweeps on an interval ranging from 5 seconds to 2 hours depending on how much it finds. Expired entries are hidden from reads immediately, but the memory they occupy is not always reclaimed promptly. On Redis 7.4 or later, or Valkey 9.0 or later, RMapCacheNative delegates expiry to the server's native hash-field TTL commands instead, so there is no eviction task to keep up with:

RMapCacheNative<String, Product> cache = redisson.getMapCacheNative("products");
cache.put(id, product, Duration.ofMinutes(10));

For a near cache, RLocalCachedMap keeps a local copy on each application instance and handles invalidation across the cluster:

LocalCachedMapOptions<String, Product> options = LocalCachedMapOptions.<String, Product>name("products")
        .evictionPolicy(EvictionPolicy.LRU)
        .cacheSize(10_000)
        .syncStrategy(SyncStrategy.INVALIDATE)
        .timeToLive(Duration.ofMinutes(10));

RLocalCachedMap<String, Product> cache = redisson.getLocalCachedMap(options);

cache.put(id, product);
Product p = cache.get(id);   // served from this instance's local memory, no network hop

syncStrategy is the setting that matters: INVALIDATE makes other instances drop their copy and reload on next access (the safe default), UPDATE pushes the new value to them proactively, and NONE disables synchronization entirely — appropriate only for data that never changes.

Redisson reports local caching delivering up to 45x faster reads and 4x faster writes than the equivalent non-local-cached map — the scale of difference a saved network round trip makes on a read-heavy workload.

Above the Map API, Redisson implements the framework integrations directly, so the cache is usually invisible in application code:

Redisson PRO extends this where the scaling limits bite. Data partitioning distributes a single local cached map's entries across multiple cluster shards, so cache capacity grows with the cluster instead of being bounded by one node. RedissonSpringLocalCachedCacheManager brings near caching to Spring's annotations, and PRO adds advanced eviction and local caching to the JCache implementation. It can be evaluated with a free trial.

Distributed Caching: Frequently Asked Questions

What Is the Difference Between a Local Cache and a Distributed Cache?

A local cache lives inside the application process, so reads are nanosecond-fast but each instance holds its own copy and those copies diverge as data changes. A distributed cache runs on separate servers and is shared by every instance, so all of them read the same value at the cost of a network round trip. Use a local cache for immutable or tolerably stale data, and a distributed cache when a stale read is a bug.

Is Redis a Distributed Cache?

Redis is an in-memory key-value store that is very commonly used as a distributed cache, and it is distributed in the full sense when run as Redis Cluster, which partitions data across multiple masters with replicas. A single Redis instance shared by several applications is already a remote cache rather than a local one, but it is not partitioned or fault-tolerant. Redis provides the storage and expiry; caching patterns such as read-through and write-behind are implemented by the client library.

What Are the Three Types of Cache?

In application architecture the usual division is local (in-process, inside the application's own memory), distributed (a shared cache tier on separate servers), and near cache (a local cache layered in front of a distributed one, kept in sync by invalidation messages). Note that the same question in a hardware context means the CPU's L1, L2 and L3 levels — see cache memory.

When Should You Not Use a Distributed Cache?

When you run a single application instance, a local cache gives you the same benefit without the network hop or the extra infrastructure. When the data is small and immutable, a local cache is simpler. When the working set is so hot that the network round trip dominates your latency budget, use a near cache rather than a plain distributed one. And when every read must reflect the latest write with no staleness window at all, the answer is not a cache but a faster database.

What Is the Difference Between a Distributed Cache and a Distributed Database?

A distributed cache holds a disposable copy of data whose authoritative version lives elsewhere; losing the cache costs performance, not data. A distributed database is the system of record and must be durable and consistent. The distinction blurs in practice — Redis and Valkey can be configured for persistence and used as a primary store — but it should be a deliberate decision, because the failure semantics are entirely different.

How Do You Implement a Distributed Cache in Java?

Run Redis or Valkey as the cache tier and connect with a Java client. With Redisson the cache is exposed as standard Java collections — RMapCache for a Map with per-entry TTL, RLocalCachedMap for a near cache — and integrates with Spring Cache, JCache and Hibernate, so most application code never calls a cache API directly.

Similar terms