Redis Caching Strategies That Actually Move the Needle: Lessons from a 40% Latency Win
Not all caching is equal. Caching the wrong thing, with the wrong TTL, at the wrong layer will give you cache invalidation bugs and zero performance gains. Here's what actually worked at AddisPay.
Haylemichael Tsega
Senior Backend Engineer · Distributed Systems · Fintech
The Myth of 'Just Add Caching'
When performance is slow, "add caching" is the first suggestion in every engineering discussion. It is also one of the most commonly misapplied optimisations. Caching the wrong layer, with the wrong key design, at the wrong TTL produces: stale data bugs, cache invalidation complexity, and often no performance improvement at all because the actual bottleneck was never the query.
The 40% latency reduction we achieved at AddisPay came from profiling first, caching second — and only caching what the data proved needed caching.
Step 1: Profile Before You Cache
Before writing a single line of caching code, instrument your API to measure where time is actually spent per request. In a Node.js/Express app, a simple middleware approach:
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
console.log(`${req.method} ${req.path} ${res.statusCode} ${Date.now() - start}ms`);
});
next();
});At AddisPay, this revealed that 70% of checkout API latency was a single database query: merchant configuration lookup on every payment request. The same merchant config — rate limits, supported currencies, webhook URLs — was fetched from MongoDB on every single checkout, even though it changed at most once a day.
Strategy 1: Read-Through Cache for Reference Data
Merchant configuration is the canonical example of read-through caching: data that is read frequently but changes rarely. The pattern:
async function getMerchantConfig(merchantId) {
const cacheKey = `merchant:config:${merchantId}`;// Try cache first const cached = await redis.get(cacheKey); if (cached) return JSON.parse(cached);
// Cache miss: fetch from DB const config = await MerchantConfig.findById(merchantId); if (!config) throw new NotFoundError(`Merchant ${merchantId} not found`);
// Cache with TTL (5 minutes) await redis.setex(cacheKey, 300, JSON.stringify(config)); return config; }
TTL choice matters. 5 minutes for merchant config is safe because the merchant portal propagates config changes asynchronously and a 5-minute delay is acceptable. For user session data: 15 minutes. For bank provider status that changes by the second: no caching.
Strategy 2: Cache Invalidation on Write
Caching without invalidation is just stale data with a fancier name. When a merchant updates their configuration, the cache must be invalidated immediately:
async function updateMerchantConfig(merchantId, updates) {
// Update database first (source of truth)
const updated = await MerchantConfig.findByIdAndUpdate(
merchantId, updates, { new: true }
);// Invalidate cache immediately await redis.del(`merchant:config:${merchantId}`);
// Optional: pre-warm the cache with new value await redis.setex( `merchant:config:${merchantId}`, 300, JSON.stringify(updated) );
return updated; }
The critical rule: always write to the database first, then invalidate the cache. Never write to the cache first — a crash between the cache write and the DB write leaves the cache with data the database does not have.
Strategy 3: Rate Limiting With Redis
Redis is also ideal for distributed rate limiting — preventing a single merchant from overwhelming the API with requests.
async function checkRateLimit(merchantId, limit = 100, windowSeconds = 60) {
const key = `rate:${merchantId}:${Math.floor(Date.now() / (windowSeconds * 1000))}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, windowSeconds);
return count <= limit;
}The sliding window key — incorporating the current time bucket — means each merchant gets a fresh 100 requests per 60-second window without any cleanup jobs.
What We Did Not Cache (And Why)
Transaction records: never cached. Financial data must always come from the authoritative database. A stale cache hit on a transaction record could show a payment as pending when it has already been processed — leading to double-charge attempts.
User authentication tokens: stored in Redis as the primary store, not as a cache. Session data living only in Redis (with appropriate TTL) is a valid pattern; caching auth tokens that also live in a database creates synchronisation complexity.
The lesson: caching is a deliberate design decision, not a default. Know exactly what you are caching, why, what the acceptable staleness window is, and how invalidation works before you write the code.
Haylemichael Tsega
Senior backend engineer with 4+ years building production systems in fintech and distributed infrastructure. Backend architect for the Dashen Super App and AddisPay payment gateway.
More Articles