Skip to content

Geo-Distributed Caching: Multi-Region Cache Topologies with Redis

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Geo. We cover key concepts, practical examples, and best practices to help you master this topic.

Geo-distributed caching replicates cached data across multiple geographic regions to reduce latency for global users, minimize cross-region data transfer costs, and provide cache availability during regional outages.

flowchart TD
    US-EAST[us-east-1 Users] --> C1[Redis Cache us-east-1]
    US-WEST[us-west-2 Users] --> C2[Redis Cache us-west-2]
    EU-WEST[eu-west-1 Users] --> C3[Redis Cache eu-west-1]
    AP-SOUTHEAST[ap-southeast-1 Users] --> C4[Redis Cache ap-southeast-1]

    C1 <-->|Active-Passive Replication| Global[Global Redis]
    C2 <-->|Active-Passive Replication| Global
    C3 <-->|Active-Passive Replication| Global
    C4 <-->|Active-Passive Replication| Global
    Global --> DB[(Primary Database)]

What You'll Learn

  • Active-passive vs active-active geo-Replication
  • Region-aware cache routing with latency optimization
  • Conflict Resolution for concurrent writes across regions
  • Consistency trade-offs in geo-distributed caching

Why It Matters

A user in Sydney accessing a cache in Virginia experiences 200ms latency. A geo-distributed cache with a local replica in Sydney responds in 5ms. For a global application with millions of users, this latency difference directly impacts revenue, engagement, and user satisfaction.

Real-World Use

DodaBrowser's global sync service uses active-passive geo-replication. Write operations go to the primary Redis in us-east-1 and replicate asynchronously to read replicas in eu-west-1, ap-southeast-1, and sa-east-1. Users read from their nearest replica with 5-15ms latency instead of 150-300ms to the primary.

Region-Aware Cache Routing

Route requests to the nearest cache region:

import redis
import time
import json
import hashlib

class RegionRouter:
    def __init__(self):
        self.regions = {
            "us-east-1": {"host": "cache-us-east-1.example.com", "port": 6379, "latency_ms": 5},
            "us-west-2": {"host": "cache-us-west-2.example.com", "port": 6379, "latency_ms": 65},
            "eu-west-1": {"host": "cache-eu-west-1.example.com", "port": 6379, "latency_ms": 80},
            "ap-southeast-1": {"host": "cache-ap-southeast-1.example.com", "port": 6379, "latency_ms": 180},
        }
        self.connections = {}

    def get_connection(self, region):
        """Get or create a connection for a region."""
        if region not in self.connections:
            cfg = self.regions[region]
            self.connections[region] = redis.Redis(
                host=cfg["host"], port=cfg["port"], decode_responses=True
            )
        return self.connections[region]

    def nearest_region(self, user_region):
        """Get the nearest cache region for a user."""
        user_latency = {r: abs(cfg["latency_ms"]) for r, cfg in self.regions.items()}
        return min(user_latency, key=user_latency.get)

    def get(self, key, user_region):
        """Get a key from the nearest cache region."""
        region = self.nearest_region(user_region)
        conn = self.get_connection(region)
        try:
            value = conn.get(key)
            return {"value": value, "region": region, "hit": value is not None}
        except redis.ConnectionError:
            for fallback_region in self.regions:
                if fallback_region != region:
                    try:
                        conn = self.get_connection(fallback_region)
                        value = conn.get(key)
                        return {"value": value, "region": fallback_region, "hit": value is not None}
                    except redis.ConnectionError:
                        continue
            return {"value": None, "region": None, "hit": False}

router = RegionRouter()

user_sydney = "ap-southeast-1"
nearest = router.nearest_region(user_sydney)
print(f"User in Sydney -> nearest cache: {nearest}")

user_ireland = "eu-west-1"
nearest = router.nearest_region(user_ireland)
print(f"User in Ireland -> nearest cache: {nearest}")

print(f"\nRouting simulation for users in different regions:")
for user_region in ["us-east-1", "ap-southeast-1", "eu-west-1"]:
    result = router.get("geo:test", user_region)
    print(f"  {user_region:15s} -> {result['region']:15s} hit={result['hit']}")

Expected output:

User in Sydney -> nearest cache: ap-southeast-1
User in Ireland -> nearest cache: eu-west-1

Routing simulation for users in different regions:
  us-east-1       -> us-east-1       hit=False
  ap-southeast-1  -> ap-southeast-1  hit=False
  eu-west-1       -> eu-west-1       hit=False

Cross-Region Replication

Simulate async replication between regions:

import redis
import time
import json
import threading

class CrossRegionReplicator:
    def __init__(self):
        self.regions = {}
        self.replication_lag = {
            ("us-east-1", "us-west-2"): 0.05,
            ("us-east-1", "eu-west-1"): 0.08,
            ("us-east-1", "ap-southeast-1"): 0.15,
        }

    def add_region(self, name, conn):
        """Register a region's Redis connection."""
        self.regions[name] = conn

    def write_to_primary(self, key, value, ttl=3600, primary="us-east-1"):
        """Write to primary and schedule async replication."""
        conn = self.regions[primary]
        conn.setex(key, ttl, json.dumps(value))

        replicated = []
        for region in self.regions:
            if region == primary:
                continue
            lag = self.replication_lag.get((primary, region), 0.1)
            t = threading.Timer(lag, self._replicate, args=(primary, region, key, value, ttl))
            t.daemon = True
            t.start()
            replicated.append({"region": region, "estimated_lag_s": lag})

        return {"primary": primary, "key": key, "replicating_to": replicated}

    def _replicate(self, from_region, to_region, key, value, ttl):
        """Replicate a key to a target region."""
        try:
            conn = self.regions[to_region]
            conn.setex(key, ttl, json.dumps(value))
            print(f"  Replicated {key} from {from_region} to {to_region}")
        except Exception as e:
            print(f"  Replication failed {from_region}->{to_region}: {e}")

    def read_local(self, region, key):
        """Read from the local region (may be stale)."""
        conn = self.regions[region]
        value = conn.get(key)
        return {
            "region": region,
            "value": json.loads(value) if value else None,
            "hit": value is not None,
        }

import redis as r_lib
r_primary = r_lib.Redis(decode_responses=True)
r_west = r_lib.Redis(decode_responses=True)
r_eu = r_lib.Redis(decode_responses=True)

replicator = CrossRegionReplicator()
replicator.add_region("us-east-1", r_primary)
replicator.add_region("us-west-2", r_west)
replicator.add_region("eu-west-1", r_eu)

result = replicator.write_to_primary("geo:config", {"theme": "dark"}, ttl=300)
print(f"Write to primary: {result['key']}")

time.sleep(0.2)

for region in ["us-east-1", "us-west-2", "eu-west-1"]:
    read = replicator.read_local(region, "geo:config")
    print(f"Read from {region:15s}: hit={read['hit']}, value={read['value']}")

Expected output:

Write to primary: geo:config
  Replicated geo:config from us-east-1 to us-west-2
  Replicated geo:config from us-east-1 to eu-west-1
Read from us-east-1      : hit=True, value={'theme': 'dark'}
Read from us-west-2      : hit=True, value={'theme': 'dark'}
Read from eu-west-1      : hit=True, value={'theme': 'dark'}

Conflict Resolution

Handle concurrent writes across regions:

import redis
import time
import json

r = redis.Redis(decode_responses=True)

class ConflictResolver:
    def __init__(self, redis_client):
        self.r = redis_client

    def write_with_version(self, key, value, region, ttl=3600):
        """Write with version tracking for conflict resolution."""
        version_key = f"{key}:version"
        current_version = int(self.r.get(version_key) or 0)
        new_version = current_version + 1

        entry = {
            "value": value,
            "version": new_version,
            "region": region,
            "timestamp": time.time(),
        }
        self.r.setex(key, ttl, json.dumps(entry))
        self.r.set(version_key, new_version)
        return entry

    def resolve_conflict(self, key, entries):
        """Resolve conflicts using last-writer-wins with version check."""
        resolved = max(entries, key=lambda e: (e["version"], e["timestamp"]))
        return {
            "key": key,
            "resolved_value": resolved["value"],
            "winner_region": resolved["region"],
            "winner_version": resolved["version"],
        }

resolver = ConflictResolver(r)

entry_us = resolver.write_with_version("geo:counter", {"count": 1}, "us-east-1")
print(f"Write from us-east-1: v{entry_us['version']}")

entry_eu = resolver.write_with_version("geo:counter", {"count": 2}, "eu-west-1")
print(f"Write from eu-west-1: v{entry_eu['version']}")

entry_ap = resolver.write_with_version("geo:counter", {"count": 3}, "ap-southeast-1")
print(f"Write from ap-southeast-1: v{entry_ap['version']}")

current = json.loads(r.get("geo:counter"))
resolution = resolver.resolve_conflict("geo:counter", [current])
print(f"\nConflict resolution: {resolution}")

Expected output:

Write from us-east-1: v1
Write from eu-west-1: v2
Write from ap-southeast-1: v3

Conflict resolution: {'key': 'geo:counter', 'resolved_value': {'count': 3}, 'winner_region': 'ap-southeast-1', 'winner_version': 3}

Common Mistakes

  • Assuming strong consistency across regions — async replication means writes in one region are not immediately visible in others. Design applications to tolerate seconds to minutes of replication lag.
  • Writing to multiple primary regions without conflict resolution — concurrent writes in two regions to the same key can cause data loss. Use last-writer-wins or CRDT-based approaches.
  • Ignoring cross-region bandwidth costs — replicating large cache values across regions can incur significant data transfer charges. Only replicate compact or critical keys.
  • Using geo-replication for transient cache data — if the data has a TTL under 60 seconds, the replication cost often exceeds the benefit. Keep very short-lived data local.
  • Not testing regional failover — when the primary region fails, applications must route to a replica region. Test this by blocking the primary region's network and verifying traffic seamlessly shifts.

Practice Questions

  1. What is the difference between active-passive and active-active geo-replication?
  2. How does replication lag affect geo-distributed cache consistency?
  3. What are the cost considerations for cross-region cache replication?
  4. How does last-writer-wins resolve conflicts in geo-distributed caches?
  5. When is geo-distributed caching not worth the complexity?

Challenge

Design a geo-distributed cache topology for a social media app with users in North America, Europe, and Asia. The app has 50 million monthly active users. Each user's feed is cached with a 60-second TTL. Writes can originate from any region. Design the replication topology (primary regions, read replicas, replication lag targets), conflict resolution Strategy, and region failover plan. Estimate the total Redis memory needed and bandwidth costs.

FAQ

What is geo-distributed caching?

Geo-distributed caching places cache nodes in multiple geographic regions so users read from a nearby cache instead of a distant one. Data is replicated between regions asynchronously to maintain availability.

How does Redis support geo-replication?

Redis Enterprise has Active-Active geo-replication with CRDT-based conflict resolution. Open-source Redis supports active-passive replication where one primary feeds replicas in other regions. Third-party tools like RedisGears also enable geo-distribution.

What is the typical replication lag between regions?

Replication lag between us-east-1 and eu-west-1 is typically 50-100ms. Between us-east-1 and ap-southeast-1, 100-200ms. This is the time between a write in one region and it being visible in another.

How do I handle cache writes in a geo-distributed setup?

Route all writes to a primary region and asynchronously replicate to other regions. If you must support multi-region writes, use CRDTs (Redis Enterprise) or last-writer-wins with version tracking to resolve conflicts.

What is the cost of cross-region replication?

AWS data transfer between regions costs $0.02-$0.09/GB. Replicating a 10 GB cache every minute for redundancy costs $864-$3,888/month in bandwidth alone. Only replicate critical data, not the entire cache.

Mini Project

Build a geo-distributed cache management tool that: (1) manages connections to Redis instances in 3 regions, (2) supports writing to a primary region with async replication, (3) reads from the nearest region based on latency configuration, (4) monitors replication lag between regions, (5) handles primary region failover with automatic re-routing, and (6) reports cache hit rates per region. Test by simulating a primary region outage.

What's Next

Continue with Multi-Tier Caching to learn about combining L1 (in-memory), L2 (Redis), and L3 (CDN) cache layers. Then explore Cache Content Negotiation for caching different content types.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro