Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 006 / 100

Where to cache: browser to database in Engineering Craft

Every layer can cache. Each has a different cost of being wrong.

Expona Admin9 min read

Where to cache: browser to database

Caching is one of the most powerful tools we have to scale systems, but it's also one of the easiest to get wrong. The question isn't whether to cache—it's where to cache. Every layer between your user's browser and your database can hold data, and each layer has a different cost when that cached data becomes stale. In this post, I'll walk through the caching layers you'll encounter in production systems, how to think about staleness, and why invalidation keeps us all up at night.

Cache layers: a journey from browser to database

Think of your system as a series of concentric circles, each one capable of storing data. The further out we go, the closer we are to the user. The further in, the more expensive a cache miss becomes, but the more accurate our data tends to be.

Browser cache with Cache-Control headers

Your user's browser is free real estate. If you can store something there, you never pay for bandwidth, computation, or database queries. The tool for this is the Cache-Control header, which tells the browser how long it can hold onto a response.

I've worked on e-commerce platforms where product images, stylesheets, and JavaScript bundles are cached for months. A user who visits your site twice in a week never re-downloads those assets. The first request is the only expensive one.

typescript
// Setting cache headers in an Express middleware
app.use((req, res, next) => {
  if (req.path.match(/\.(js|css|png|jpg|gif)$/)) {
    // Static assets: cache for 1 year
    res.set('Cache-Control', 'public, max-age=31536000, immutable');
  } else if (req.path === '/api/products') {
    // Product list: cache for 5 minutes
    res.set('Cache-Control', 'public, max-age=300');
  } else if (req.path.match(/\/api\/user\//)) {
    // User data: don't cache
    res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
  }
  next();
});

The key insight here is immutability. If you're versioning your assets (e.g., bundle-abc123.js instead of bundle.js), you can cache them forever. If the asset changes, it gets a new filename, so old browsers still have the old version cached—and that's fine because they're different files.

CDN: static assets and pages at the edge

A CDN (Content Delivery Network) sits between your users and your servers. It caches responses at edge locations around the world, so a user in Singapore gets responses served from a nearby data center instead of waiting for a roundtrip to your US servers.

I've deployed SaaS products where the marketing site, product images, and even some API responses are cached at the CDN. Cloudflare, AWS CloudFront, and Fastly all work on the same principle: they respect your Cache-Control headers and store responses closer to users.

The beauty of a CDN is that you don't need to change your code. You set the cache headers, point your DNS to the CDN, and suddenly your content is distributed globally. A 10MB image download that used to take 800ms from your server might take 50ms from an edge location.

The fastest request is the one that never reaches your server. A CDN request that hits a cached response never leaves the edge network.

Application cache: Redis or in-memory stores

Once requests reach your application, you still have a chance to avoid hitting your database. This is where Redis or in-memory caches like local Node.js objects come in.

In a trading platform I built, we cached market data in Redis with a 2-second TTL. For a user refreshing prices rapidly, most requests hit Redis instead of recalculating from our event stream. When the 2 seconds expired, we'd refresh, but in between, thousands of requests were served from memory.

typescript
// Express + Redis caching pattern
const redis = require('redis');
const client = redis.createClient();

app.get('/api/user/:id/portfolio', async (req, res) => {
  const cacheKey = `portfolio:${req.params.id}`;
  
  // Try cache first
  let portfolio = await client.get(cacheKey);
  if (portfolio) {
    return res.json(JSON.parse(portfolio));
  }
  
  // Cache miss: hit the database
  portfolio = await db.query(
    'SELECT * FROM portfolios WHERE user_id = $1',
    [req.params.id]
  );
  
  // Store in Redis for 30 seconds
  await client.setEx(cacheKey, 30, JSON.stringify(portfolio));
  
  res.json(portfolio);
});

Application caching is where you have control. You decide the TTL, you decide what to cache, and you can invalidate on demand. A database query that takes 100ms and returns the same data repeatedly? Cache it for 5 seconds and drop the cost to near zero.

Database cache: buffer pool and materialized views

Even your database is caching. PostgreSQL maintains a buffer pool—pages of disk data that are kept in memory. Queries that hit the buffer pool are orders of magnitude faster than disk reads.

Beyond that, you can create materialized views: pre-computed query results stored as tables. In a CRM system I worked on, we computed daily sales summaries into a materialized view that refreshed every hour. Reports that would have taken 30 seconds to query from raw data ran in milliseconds against the view.

typescript
// PostgreSQL materialized view for daily aggregates
CREATE MATERIALIZED VIEW daily_sales_summary AS
SELECT
  DATE(created_at) as sale_date,
  region,
  COUNT(*) as num_sales,
  SUM(amount) as total_revenue,
  AVG(amount) as avg_order_value
FROM orders
WHERE created_at >= NOW() - INTERVAL '90 days'
GROUP BY DATE(created_at), region;

-- Refresh periodically (e.g., hourly in a cron job)
-- REFRESH MATERIALIZED VIEW daily_sales_summary;

You can't query a materialized view as fresh as a live table, but for many read-heavy workloads, that trade-off is worth it. The view sits in the database's cache, returns instantly, and reduces CPU load on your main tables.

Cache close to the user

There's a hierarchy of latency. A roundtrip to the user's disk cache takes microseconds. A roundtrip to your CDN takes milliseconds. A roundtrip to your application takes tens of milliseconds. A roundtrip to your database takes hundreds of milliseconds.

The principle is simple: cache as close to the user as you can. In a ride-hailing app I built, we cached the list of available drivers at the CDN level for 10 seconds. Every passenger who opened the app got results from the edge, not from our application. Only when a passenger actually booked a ride did we query the fresh database.

This doesn't mean everything should be cached at the browser. It means you should be intentional about where each piece of data lives. Marketing content? Browser cache it. Product catalogs? CDN cache. User profile data? Application cache with a short TTL. Account balances? Don't cache, or cache for single-digit seconds with explicit invalidation on writes.

Each layer you can bypass saves latency and reduces load downstream. A user's experience improves, and your infrastructure costs drop. That's the win.

Know your staleness budget

Every piece of cached data has an acceptable window of time that it can be out of date. Some data tolerates a lot of staleness. Some tolerates none.

In an e-commerce platform, product prices displayed to customers can be 30 seconds stale. If a price changed at 12:00:00 and a customer sees the old price until 12:00:30, that's acceptable. You might have 500 price changes a day; invalidating cache for each one is expensive.

Account balances in a fintech app? Those need to be fresh within seconds, maybe less. If a customer just transferred $5,000 and checks their balance immediately, they need to see the updated number. You'd cache account balance for 5 seconds maximum, with explicit invalidation on every transfer.

The key is knowing which category your data falls into before you cache it. Ask: what's the worst thing that happens if this data is 10 seconds old? 1 minute old? 1 hour old? The answer tells you your TTL.

Data typeStaleness toleranceCache TTL
Marketing content1 hour1 hour
Product catalog5 minutes5 minutes
Product prices30 seconds30 seconds
User profile2 minutes2 minutes
Account balance5 seconds5 seconds + explicit invalidation
Real-time inventoryNoneNo caching or write-through validation

Invalidation is the hard part

Phil Karlton said "There are only two hard things in Computer Science: cache invalidation and naming things." He wasn't wrong.

Setting a TTL and letting it expire is simple but crude. For data that changes frequently or needs to be fresh quickly, you need explicit invalidation: when data changes, you actively remove it from cache.

In a travel platform I built, hotel prices are cached for 1 minute. But when a booking is made and inventory changes, we immediately invalidate the cache for that hotel so the next user sees fresh availability. We do both: TTL as a safety net, and explicit invalidation on writes.

typescript
// Explicit invalidation on write
app.post('/api/hotels/:id/book', async (req, res) => {
  // Update database
  const booking = await db.query(
    'INSERT INTO bookings (...) VALUES (...) RETURNING *',
    [...]
  );
  
  // Invalidate cache for this hotel
  await redis.del(`hotel:${req.params.id}:availability`);
  await redis.del(`hotel:${req.params.id}:prices`);
  
  // Also invalidate listing pages that include this hotel
  await redis.del(`listings:search:${hotelCity}`);
  
  res.json(booking);
});

The challenge is knowing what to invalidate. If you cache the result of a complex query, and a write happens, which cache keys might be affected? You might have to invalidate multiple keys. You might have to invalidate entire segments of your cache.

This is why cache-aside (your application loads and stores) is often simpler than write-through (your cache is updated as part of the write). With cache-aside, you control exactly what gets cached and when it's invalidated. With write-through, you have to ensure consistency between the database and cache, which gets complicated fast.

Most of my successful cache strategies use a combination: short TTLs (10 seconds, 30 seconds, a few minutes) plus explicit invalidation on writes. The TTL prevents stale data from lingering forever if an invalidation is missed. The explicit invalidation gives fresh data to users immediately after a write.

Common mistakes

  • Caching without knowing the staleness budget. You cache data without asking whether it needs to be fresh. This causes subtle bugs where users see outdated information. Always ask: how stale is too stale?
  • Invalidating too aggressively. Invalidating cache on every write seems safe but defeats the purpose. If you invalidate on every price change in a high-volume system, you're thrashing the cache and hurting performance. Trust your staleness budget.
  • Forgetting about the CDN. You set up browser caching and application caching but forget the CDN is in the middle, caching too. Your API responses are stale because the CDN is serving an old version. Check your Cache-Control headers at every layer.
  • Not measuring cache hit rates. You implement caching but don't monitor whether it's actually being used. A cache with a 10% hit rate is wasting memory. Measure and adjust.
  • Over-caching user-specific data. You cache a user's preferences at the CDN level with a long TTL, and now different users are seeing each other's preferences. User-specific data should be cached only at the browser or application level, never at the CDN.

Key takeaways

  • Cache exists at every layer: browser, CDN, application, and database. Each layer has different latency and consistency tradeoffs.
  • The fastest request is the one prevented by browser or CDN caching. Push data out to the edge when possible.
  • Know your staleness budget before you cache. Prices tolerate 30 seconds. Account balances might tolerate 5 seconds. Inventory might tolerate none.
  • Use short TTLs (10-300 seconds) combined with explicit invalidation on writes. This covers most cases without overthinking consistency.
  • Measure cache hit rates. A cache nobody hits is wasted memory.
  • Cache invalidation is genuinely hard. Start simple: use the CDN for static assets, Redis for frequently-accessed data, and application logic for knowing what to invalidate on writes.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles