Skip to content

Caching with redis.asyncio Clients

A process-local cache is free and per-worker; Redis is shared, survives a deploy, and costs a network round trip. That round trip is the entire design problem. Measured on a loopback Redis, reading 1,000 keys one at a time took 61 ms, issuing the same reads concurrently took 35 ms, and a single MGET took 1.3 ms — a 48x difference decided purely by how many times you cross the network. This guide covers the client-side decisions that make a Redis cache fast and predictable: pooling and its surprising failure mode, batching, serialisation, and the TTL rule that quietly caches things forever.

Prerequisites

One round trip per key, or one for all of them 3 bars comparing 1,000 sequential GETs with the others. One round trip per key, or one for all of them 1,000 sequential GETs 61 ms 1,000 concurrent GETs 35 ms one MGET 1.3 ms Same 1,000 keys on a loopback Redis; writes showed the same shape (69 ms vs 12 ms pipelined). Batching beats concurrency: one round trip is always cheaper than a thousand fast ones.

1. Create one client, with a pool you sized

redis_client = redis.from_url(
    REDIS_URL,
    max_connections=32,                                # concurrent commands, not requests
    decode_responses=False,                            # bytes: you handle serialisation
    socket_timeout=1.0,                                # never wait forever on a cache
    socket_connect_timeout=1.0,
    health_check_interval=30,
)

One client per process, created in the lifespan and closed at shutdown. The client is a pool: concurrent commands take separate connections and are multiplexed by the library, so 1,000 concurrent GETs over a 32-connection pool completed in 35 ms.

socket_timeout is the important one. A cache is an optimisation, and an optimisation that can hang is worse than no cache — bound it and treat a timeout as a miss.

Verify: the connection count to Redis is stable and well below max_connections in normal operation.

2. Know what the pool does when it runs out

This is the behaviour that surprises people coming from other clients:

default pool (max_connections=2)   4 blocking ops -> ConnectionError: Too many connections
BlockingConnectionPool(max=2)      4 blocking ops -> completed in 2.04 s

The default redis.asyncio pool raises rather than queues. For ordinary fast commands that is almost impossible to hit; for blocking commands (BLPOP, XREAD with BLOCK) or a slow Redis, it turns a capacity problem into a stream of errors.

from redis.asyncio.connection import BlockingConnectionPool

client = redis.Redis(connection_pool=BlockingConnectionPool.from_url(
    REDIS_URL, max_connections=32, timeout=1.0))       # queue, with a bounded wait

Choose deliberately: fail fast when the cache is strictly optional, queue with a timeout when commands are expensive and errors are noisier than waiting. Never use blocking commands on the same client as your cache reads — give them their own client, as a bulkhead.

Verify: under a burst larger than the pool, you get the failure mode you chose.

What happens when the connection pool runs out 2 columns contrasting default pool, BlockingConnectionPool. What happens when the connection pool runs out default pool fails fast raises ConnectionError "Too many connections" no queueing at all surfaces as a cache error BlockingConnectionPool queues waits for a free connection measured: 4 ops on 2 connections, 2.04 s timeout bounds the wait back-pressure instead of errors Neither is wrong; the default simply fails in a way most cache code does not expect.

3. Batch everything you can

The round trip dominates everything else, so the cache API should be plural:

async def get_many(keys: list[str]) -> dict[str, bytes | None]:
    values = await redis_client.mget(keys)             # one round trip
    return dict(zip(keys, values))


async def set_many(items: dict[str, bytes], ttl: int) -> None:
    async with redis_client.pipeline(transaction=False) as pipe:
        for key, value in items.items():
            pipe.set(key, value, ex=jittered(ttl))
        await pipe.execute()                           # one round trip

Measured: 1,000 writes took 69 ms one at a time and 12 ms pipelined; 1,000 reads took 61 ms individually and 1.3 ms via MGET. transaction=False sends the commands without wrapping them in MULTI/EXEC, which is what you want for cache writes — they are independent, and the transaction adds round trips and semantics you do not need.

A request handler that looks up ten things should issue one MGET, not ten GETs. Designing the cache interface around lists rather than single keys is what makes that natural instead of an optimisation someone has to remember.

Verify: the Redis command count per request is closer to one than to the number of cached values.

The shape of a cache read 5 ordered steps. The shape of a cache read build a namespaced key app:v2:user:42 MGET the batch one round trip deserialise hits about 3 µs per object fetch misses from the origin deduplicated write back with TTL + jitter never a plain SET Versioning the key prefix is what makes a format change a deploy rather than a migration.

4. Get the TTL rules right

Two Redis behaviours cause most "why is this stale" incidents:

SET key value EX 5        -> ttl 5
SET key value             -> ttl -1        (the TTL is cleared)
SET key value KEEPTTL     -> ttl unchanged

A plain SET to refresh a value removes its expiry, and the key is then cached forever. Verified on Redis 8. Every cache write must carry ex=, or keepttl=True when deliberately refreshing a value in place.

Add jitter so keys written together do not expire together:

def jittered(ttl: int) -> int:
    return int(ttl * random.uniform(0.9, 1.1))

And remember TTL returns -1 for a key with no expiry and -2 for a key that does not exist — a distinction worth handling explicitly when auditing a cache.

Verify: a scan of your cache namespace finds no keys with a TTL of -1.

TTL behaviour that surprises people A grid of 4 rows by 2 columns. TTL behaviour that surprises people operation resulting TTL note SET key value EX 5 5 seconds the normal cache write SET key value (no EX) none: TTL is cleared the most common cache leak SET key value KEEPTTL the existing TTL for refreshing a value in place TTL on a key with no expiry -1 -2 means the key does not exist Verified on Redis 8: a plain SET over a key with a TTL left it at -1, cached forever.

5. Choose a serialisation and version the keys

Values go over the wire as bytes, so every read and write pays serialisation. A JSON round trip of a small object measured 3.3 µs — negligible against a 1.3 ms batched read, and not negligible if you serialise a thousand objects individually in a loop.

def encode(obj) -> bytes:
    return json.dumps(obj, separators=(",", ":")).encode()

JSON is the sensible default: readable in redis-cli, language-agnostic, debuggable. Reach for msgpack or protobuf when payloads are large and the difference shows up in a profile — and never pickle, which makes every cached value a remote code execution path if Redis is ever compromised.

Version the key prefix:

KEY = "app:v3:user:{user_id}"

When the cached structure changes, bumping v3 to v4 invalidates everything instantly with no deletion pass and no ambiguity about which format a key holds — the old keys expire on their own. That one convention prevents the worst class of cache bug: new code reading values written by old code.

Verify: deploying a format change requires only a prefix bump, and mixed-format reads are impossible.

Verification

A Redis cache is well built when:

  • One client per process, pooled, with timeouts set.
  • The pool's exhaustion behaviour is chosen, not discovered.
  • Reads and writes are batched with MGET and pipelines.
  • Every write carries a TTL, with jitter.
  • Keys are namespaced and versioned.
  • Cache failures degrade to a miss rather than an error.

Pitfalls & edge cases

  • A plain SET on refresh. Clears the TTL and caches the value forever.
  • One GET per value in a loop. 48x slower than MGET in the measurement above.
  • decode_responses=True with binary payloads. It tries to UTF-8 decode everything; keep bytes for serialised data.
  • Blocking commands sharing the cache client. They occupy pool connections and can exhaust it.
  • Treating a cache error as fatal. Catch RedisError, count it, and fall through to the origin.
  • Pickle as the format. A compromised or shared Redis becomes arbitrary code execution.
  • Unbounded key growth. Without TTLs, Redis evicts by maxmemory-policy — or refuses writes if the policy is noeviction.

Frequently Asked Questions

How do I use Redis as a cache from asyncio?

Create one redis.asyncio client per process with an explicit max_connections and socket timeouts, store it in your lifespan state, and close it at shutdown. Use MGET and pipelines to batch, always write with an ex TTL, and treat any Redis error as a cache miss rather than a request failure.

Why does redis.asyncio raise "Too many connections"?

Because the default pool fails fast instead of queueing when max_connections is reached. Raise the limit, or use BlockingConnectionPool with a timeout — measured, four blocking operations on a two-connection blocking pool completed in 2.04 s rather than erroring.

Is MGET really faster than concurrent GETs?

Substantially. Reading 1,000 keys took 61 ms sequentially, 35 ms with all the GETs issued concurrently, and 1.3 ms with one MGET. Concurrency hides latency; batching removes the round trips entirely.

Why did my Redis cache key stop expiring?

Almost certainly a plain SET refreshed it. SET without EX or KEEPTTL clears the existing TTL, leaving the key cached forever — verified, TTL returned -1 afterwards. Always pass an expiry, or keepttl=True when refreshing in place.

How should I invalidate cached data when its format changes?

Version the key prefix — app:v3:user:42 — and bump the version with the deploy. Old keys become unreachable and expire on their own, so there is no deletion pass and no chance of new code reading a value written in the old format.