Skip to main content
Why CacheeHow It Works
All Verticals5G TelecomAd TechAI InfrastructureFraud DetectionGamingTrading
PricingDocsBlogSchedule DemoLog InStart Free Trial
← Back to Blog
Performance

We Added Caching and Response Time Got Worse — Here’s the Fix

You added Redis. You expected a 10x improvement. Your team celebrated. Then the dashboards loaded. Median response time dropped 30% — good, but not the order-of-magnitude win you projected. And your P99? It actually went up. From 45ms to 62ms. You double-checked the metrics. You restarted the service. You checked for regressions in the deploy. Nothing. The cache was working. It was also making things worse. This is more common than anyone admits, and the fix is not what you think.

The instinct is to tune Redis — adjust maxmemory-policy, increase the connection pool, enable pipelining. But the problem is not Redis. The problem is that inserting a remote cache into a request path introduces four categories of overhead that can individually or collectively negate the latency savings. Understanding each one is the difference between a cache that accelerates your application and a cache that quietly degrades it.

4 Ways Caching Makes Things Worse

1. Serialization Overhead on Large Objects

Redis stores bytes, not objects. Every cache write requires serializing your application object into a byte stream — JSON, MessagePack, Protocol Buffers, or whatever format your client uses. Every cache read requires deserializing it back. For small payloads (a user session token, a feature flag, a simple string), this overhead is negligible: 5–20 microseconds. But the objects teams actually cache tend to be large. A product catalog response with nested variants, images, and pricing tiers. A user profile with permissions, preferences, and activity history. A dashboard aggregation with 30 days of time-series data.

A 1.2MB JSON object takes approximately 4ms to serialize and 3ms to deserialize — 7ms of pure CPU work before any network transfer happens. If the database query that produced this object takes 8ms, you have consumed 87% of your savings on serialization alone. At 500KB, you are looking at roughly 2ms each way. At 100KB, around 0.8ms. The breakeven point depends on your database latency, but the principle is universal: the larger the cached object, the smaller the net benefit of caching it remotely.

// Typical hidden cost: serialization eats most of the cache benefit const product = await db.query('SELECT * FROM products WHERE id = ?'); // 8ms const serialized = JSON.stringify(product); // 4ms (1.2MB payload) await redis.set('product:123', serialized); // 1ms network // Total write: 13ms. You saved nothing on this request. // Cache hit path: const cached = await redis.get('product:123'); // 1ms network const parsed = JSON.parse(cached); // 3ms deserialize // Total read: 4ms. DB was 8ms. You saved 4ms, not 7ms.

2. Network Round-Trip Where None Existed

Before you added a cache, your request path was simple: application queries database, database returns result. One network hop. After adding Redis, a cache hit replaces that database hop with a Redis hop — typically faster, which is the point. But a cache miss adds a hop. The application checks Redis (1ms), gets a miss, queries the database (5ms), then writes the result back to Redis (1ms). What was a 5ms operation is now a 7ms operation. You made misses 40% slower.

If your cache hit rate is 95%, this math works out in your favor overall. But during cold starts, after deployments, or after a cache flush, your hit rate temporarily drops to 0%. Every single request pays the miss penalty. If your application handles 5,000 requests per second and hit rate drops to 60% for two minutes after a deploy, that is 2,000 requests per second each eating an extra 2ms — 4 seconds of cumulative added latency per second. Your P99 explodes because tail latencies during miss storms are strictly worse than having no cache at all.

The miss penalty math: Before cache: App → DB = 5ms. After cache miss: App → Redis (1ms) → miss → DB (5ms) → write Redis (1ms) = 7ms. Every miss is 40% slower than no cache. At 60% hit rate, your average latency is 0.6 × 1ms + 0.4 × 7ms = 3.4ms. Only marginally better than 5ms — and your P99 is worse because miss paths stack. See reducing Redis latency for mitigation strategies.

3. Cache Stampede on Expiry

You set a TTL of 60 seconds on a hot key. Sixty seconds later, the key expires. In the next 10 milliseconds, 200 concurrent requests arrive for the same key. All 200 check Redis. All 200 get a miss. All 200 query the database simultaneously. Your database connection pool saturates. Queries that normally take 5ms now take 150ms because they are queuing behind 199 other identical queries. All 200 requests then attempt to write the result back to Redis — 200 redundant writes. This is a cache stampede, and it happens on every popular key at every TTL boundary. The more popular the key, the worse the stampede. The shorter the TTL, the more frequently it occurs.

Stampedes are particularly insidious because they look like database problems, not cache problems. Your monitoring shows database latency spiking every 60 seconds. The natural response is to increase database connection pools or scale read replicas. But the root cause is the cache layer itself — specifically, the gap between expiry and repopulation. Standard mitigations include probabilistic early expiration, mutex locks (only one request repopulates; others wait), and staggered TTLs. These all add complexity and code to manage, and they all have edge cases where stampedes still break through.

4. Double-Write Penalty

Every write operation now hits two systems instead of one. Update a user profile: write to the database (3ms), then invalidate or update the cache (1ms). That is a 33% latency increase on every write. But the real cost is not the latency — it is the consistency problem. If the database write succeeds but the cache invalidation fails (network blip, Redis timeout, process crash between the two operations), your cache now serves stale data indefinitely. If you invalidate the cache first and the database write fails, you have a cache miss that will repopulate with the old data on the next read — but with a gap where reads hit the database unnecessarily. There is no ordering of operations that guarantees consistency between a database and a remote cache without distributed transactions, and nobody wants distributed transactions on their hot path.

// The double-write dilemma: no safe ordering exists // Option A: Write DB first, then invalidate cache await db.update('users', { id: 123, name: 'Alice' }); // 3ms await redis.del('user:123'); // 1ms — what if this fails? // Cache serves stale data until TTL expires // Option B: Invalidate cache first, then write DB await redis.del('user:123'); // 1ms await db.update('users', { id: 123, name: 'Alice' }); // 3ms — what if this fails? // Concurrent read between del and update repopulates stale data

The Serialization Trap

This problem deserves special emphasis because it is the least visible and the most common. Teams profile their database queries. They see a 15ms query that runs 10,000 times per day. They add Redis caching. The cache hit returns in 1ms. Case closed — except nobody profiled the serialization. That 15ms query returns a 400KB result set. JSON.stringify on 400KB costs 1.8ms. JSON.parse on the cached version costs 1.4ms. The actual improvement is 15ms to 4.2ms — meaningful, but not the 15ms to 1ms that the team projected and reported.

The trap deepens with nested objects. A 200KB payload with deeply nested arrays and objects can take longer to deserialize than a flat 400KB payload because JSON parsers must allocate and link objects recursively. MessagePack and Protocol Buffers reduce the overhead by 40–60%, but they do not eliminate it. Any format that crosses a process boundary requires encoding, transmission, and decoding. If your cached object is over 100KB, you should benchmark whether the serialization cost plus network transfer actually beats a well-indexed database query. In many cases, it does not.

Rule of thumb: If your cached object exceeds 100KB, profile the full round-trip (serialize + network + deserialize) against the raw database query. You may find that the cache is a net negative for that specific payload. Consider caching smaller, pre-shaped views of the data instead of the full object graph.

The Network Hop You Added

Before the cache, your architecture had a clear latency path. Application process queries database, database responds. One hop, one failure mode, one thing to monitor. After adding Redis, the happy path (cache hit) is faster, but you have added an entirely new network dependency to every read operation. Same-rack Redis adds 0.5–1ms per round-trip. Cross-AZ Redis (common in multi-AZ deployments for durability) adds 2–5ms. ElastiCache in a different subnet, behind a NAT gateway or VPC peering connection, can add 3–8ms of base latency.

Under load, these base numbers deteriorate. Connection pool contention adds wait time. TCP window scaling and Nagle’s algorithm interact badly with small Redis payloads. TLS termination (required for compliance in many environments) adds 1–2ms per new connection. When you sum these up, the network hop you added “for performance” can cost 5–10ms on the tail — on a cache hit. On a miss, you pay that cost plus the original database latency plus the write-back. The network hop is not free. It is the minimum price of every remote cache interaction, and no amount of Redis tuning can make it zero.

0.5ms Same-Rack Redis
2–5ms Cross-AZ Redis
0ms In-Process L1

The Real Fix: In-Process L1

Every problem described above shares a root cause: the cache is a remote process accessed over a network. Serialization exists because you must convert objects to bytes to send them over the wire. The network hop exists because Redis runs in a separate process, usually on a separate machine. Stampedes exist because expiry and repopulation are not atomic across a distributed system. Double-write inconsistency exists because you are coordinating two remote systems without transactions.

An in-process L1 cache eliminates all four. Objects stay in the application’s own memory — no serialization, because the runtime holds native object references. There is no network hop, because the lookup is a hash table access in the same process — 1.5 microseconds, not 1 millisecond. There are no stampedes, because predictive pre-warming replaces TTL-based expiration: the cache learns access patterns and refreshes data before it is requested, so there is never a gap between expiry and repopulation. And write consistency is simplified because the L1 cache receives invalidation events in real time from the backing store — no manual double-write logic required.

This is the architecture Cachee implements. It deploys as a transparent layer — either an SDK or a sidecar proxy — that intercepts cache reads and serves them from L1 in-process memory. Redis (or any backing store) remains the system of record for writes and cold reads. But the hot path — the reads that account for 90–99% of your cache traffic — never leaves the application process. The result is not just faster cache reads. It is the elimination of every failure mode that made caching worse in the first place.

What changes with L1: Serialization overhead goes to zero (native objects in memory). Network round-trip goes to zero (same-process lookup). Stampedes go to zero (predictive pre-warming replaces TTL expiry). Double-write complexity goes to zero (event-driven invalidation). The cache stops being a remote system you manage and becomes an invisible acceleration layer.

Before and After

Here is the latency waterfall for the same endpoint — a product detail page querying a 300KB cached object — with a traditional remote cache versus Cachee’s L1 tier. The traditional path includes serialization, network transfer, and deserialization. The L1 path is a single hash table lookup in the application process.

Traditional Remote Cache (Redis, 300KB Object)

Application request
0 ms
Connection pool acquire
0.5 ms
TCP round-trip (send)
1 ms
Redis GET execution
0.3 ms
Network transfer (300KB)
1.5 ms
Deserialization (JSON.parse)
1.8 ms
Object allocation
0.9 ms
Total (cache hit) 6 ms
Total (cache miss) 14 ms

Cachee L1 In-Process Cache

Application request
0 ms
L1 hash table lookup
0.0015 ms
Return (zero-copy ref)
0.0005 ms
Total 0.002 ms

That is 6ms vs. 0.002ms on a cache hit. A 3,000x improvement. On a miss path, the delta is even larger: 14ms with the remote cache (because you pay the miss penalty plus DB plus write-back) versus 0.002ms with L1 (because predictive pre-warming means the data is already in memory before the request arrives). The entire serialization stack, the entire network stack — gone. Not optimized. Not tuned. Eliminated.

3,000× Faster Than Remote Cache
0ms Serialization Cost
0ms Network Overhead
99%+ L1 Hit Rate

Further Reading

Also Read

Stop Making Caching Worse. Start Making It Invisible.

See how in-process L1 lookups eliminate serialization, network hops, and stampedes — permanently.

Start Free Trial Schedule Demo