Module 3 / Caching

Module 3 · lesson 2 of 7 · Read 9 min

Where caches live

Browser, CDN, server memory and Redis, and what each is good for.

"Add a cache" sounds like one decision. It's really two: what to cache, and where. Data can be cached on the customer's phone, at a server in their city, inside your app server's memory, or in a shared cache like Redis. Each place has a different speed, a different reach, and a very different answer to "how do I fix it when it's wrong?".

In this lesson we'll walk the path from the phone to the database, look at each place a cache can sit, and decide what Tiffin should cache where.

The layers, from near to far

%% caption: Each layer can answer a request before it travels further. The nearer the layer, the faster, and the harder to correct.
flowchart TD
  P(["Phone<br/>app and browser cache"]):::hot --> CDN["CDN<br/>servers near the user"]
  CDN --> SM["Server memory<br/>inside the app"]
  SM --> R[("Redis<br/>shared cache")]:::store
  R --> D[("Database<br/>the source of truth")]:::store
Each layer can answer a request before it travels further. The nearer the layer, the faster, and the harder to correct.
Layer Typical speed Shared by Easy to correct? Good for
Phone or browser 0 ms, no network One user No: you can't reach into the phone Images, app files, data that's fine to be a bit old
CDN 5 to 30 ms Everyone near one CDN location Somewhat: purges take seconds to minutes Images, scripts, public responses that are the same for everyone
Server memory ~0.0001 ms Requests on one server Only on that server Small, very hot, rarely changing data: config, feature flags
Shared cache (Redis) ~0.5 ms All servers Yes: one delete fixes it Menus, restaurant lists, anything computed and shared
Database (its own cache) 1 ms and up Everyone It is the truth Everything else

Let's take them in turn.

On the phone: the client cache

The fastest request is the one never sent. Phones and browsers keep copies of responses they've already received, and HTTP has a whole system of headers for telling them what they may keep and for how long.

For a dish photo, the server can send:

Cache-Control: public, max-age=31536000, immutable

That says: anyone may cache this, for a year, and it will never change. The phone won't ask for it again.

"Never change" sounds risky for a photo the restaurant might replace. The trick is to put a version or a hash of the content in the URL:

https://img.tiffin.app/dishes/7/a3f9c2e1.jpg

When the restaurant uploads a new photo, it gets a new hash, and the menu points at a new URL. The old one stays cached forever and simply stops being used. This is called cache busting, and it's the standard way to cache static files: never change what's at a URL; change the URL.

For data that does change, like a menu, you can still allow a short client cache:

Cache-Control: private, max-age=30
ETag: "menu-42-v17"

private means only this phone may cache it, not shared caches along the way. After 30 seconds the phone asks again, but sends If-None-Match: "menu-42-v17". If nothing changed, the server replies 304 Not Modified with no body: a tiny response instead of 30 KB.

The big limitation of the client cache: once it's on the phone, you can't take it back. If you send max-age=86400 on a menu and the price changes, that customer sees the old price for up to a day. So client caching suits things that never change (versioned files) or things where a little staleness is fine (and checkout re-checks prices on the server anyway).

At the edge: the CDN

A CDN (content delivery network) is a company that runs caching servers in hundreds of locations around the world. You point a domain, like img.tiffin.app, at it. When a phone asks for a photo, the request goes to the nearest CDN location. If that location has it cached, it answers immediately. If not, it fetches it once from your server (or object storage), keeps it, and serves everyone nearby from then on.

Remember the estimate from Module 2: photos alone would push about 80 Mbit/s out of Tiffin's server at dinner time, and that was at launch-year traffic. Today it would be about four times that. With a CDN, almost all of that is served by the CDN's machines. Our server barely sees photo traffic at all.

CDNs are great for:

  • Images, video, app bundles, JavaScript and CSS: big files, same for everyone.
  • Public API responses that are identical for everyone in an area, if you're careful with headers.

They're wrong for anything personal. A CDN that caches /me/orders and serves one customer's orders to the next person is a serious data leak. Responses that depend on who's asking must be marked private or no-store.

Correcting a CDN is possible but slow-ish: you send a purge request and wait seconds to minutes for every location to drop the copy. Versioned URLs avoid needing that at all. We'll see CDNs again in Module 18, for routing users around the world.

Inside the server: in-process memory

The app server itself can keep data in a variable: a map from key to value, living in its own memory.

const local = new Map() // key -> { value, expiresAt }

It's the fastest cache there is. No network, no serialisation, about a ten-thousandth of a millisecond. But it has real limits:

  • It's private to one server process. When Tiffin runs ten servers (Module 4), there are ten separate caches, each with its own copy, each going stale on its own schedule. Deleting a key on one doesn't touch the others.
  • It vanishes on restart. Every deploy starts with an empty cache. If you deploy at 8pm, the database gets the full load at the worst moment.
  • It uses the server's memory. Cache too much and the app runs out.

So in-process caching suits small, hot data that changes rarely and where a little staleness across servers is fine: delivery fee rules, feature flags, the list of cities. Keep it for seconds to minutes and let it refresh on its own.

A shared cache: Redis

Redis (and its older cousin Memcached) is a separate server that keeps data in memory and answers lookups over the network. Every app server talks to the same Redis.

GET menu:42        -> the cached menu, or nothing
SET menu:42 "<json>" EX 600
DEL menu:42

Because there's one shared copy:

  • All servers see the same thing. Delete a key once and every server gets fresh data on the next request.
  • It survives app deploys. Restart the app servers as often as you like; the cache is still warm.
  • It's still very fast. About 0.5 ms including the network round trip inside the data centre. One Redis server can handle 100,000 or more simple operations a second.

Its cost is that it's another server to run and monitor, and another thing that can fail. Most cloud providers offer it as a managed service, which takes care of most of that.

Tiffin still runs on one app server. Why not just use server memory? It would work today. But every deploy would empty it at full traffic, and the next module adds more servers, where per-server copies drift apart. Redis avoids both problems for half a millisecond per lookup. When the next step is that close, it's worth taking it now.

The database has a cache too

One more layer you get for free. Databases keep recently used data pages in memory (PostgreSQL calls this its shared buffers, and also relies on the operating system's file cache). That's why a query is much faster the second time: the data is already in memory.

This is why a database with enough RAM to hold its hot data feels fast, and why the same database slows down sharply once the hot data no longer fits. It doesn't remove the work of running the query, though. Even from memory, our menu query still joins hundreds of rows. A Redis cache stores the finished answer.

Tiffin's choices

Data Where How long
Dish photos, restaurant banners CDN and phone, versioned URLs A year
App's JavaScript and CSS (web) CDN and browser, versioned URLs A year
Menus Redis, plus a short max-age on the phone Deleted on change, 10-minute TTL as a backstop (Redis); 30 seconds (phone)
Open restaurants in an area Redis 60 seconds
Delivery fee rules, feature flags Server memory 1 minute
A customer's orders, payments Not cached Always from the database

Notice the pattern: big, unchanging, public things go as close to the user as possible. Shared, changing things go in Redis, where one delete fixes them. Personal and money-related things aren't cached at all.

Place the cache

Where would you cache each of these, and for how long? (a) Tiffin's terms and conditions page, (b) a restaurant's average rating, shown on its card, (c) the logged-in customer's profile name and photo, shown on every screen, (d) the list of cities Tiffin operates in.

Show one answer

(a) CDN and browser. It's public and changes a few times a year. Cache for an hour or a day, and purge the CDN if you update it urgently.

(b) Redis, as part of the restaurant data, with a TTL of several minutes. Nobody needs the average to update the instant a rating arrives.

(c) On the phone. The app keeps it locally after login and refreshes it occasionally. It's personal, so never in a shared CDN.

(d) Server memory (and on the phone). Tiny, read constantly, changes when Tiffin launches a city, a few times a year. Refresh every few minutes.

What to remember

  1. Caches can live on the phone, in a CDN, in the server's memory, in a shared cache like Redis, and inside the database itself.
  2. The nearer the cache to the user, the faster it is and the harder it is to correct.
  3. Never change what's at a URL; change the URL. Versioned URLs let you cache static files for a year safely.
  4. CDNs are for big, public, identical content. Never let a shared cache store personal responses.
  5. In-process caches are fastest but per-server and lost on restart. Redis is shared, survives deploys, and costs about half a millisecond.
  6. Put big unchanging things near the user, shared changing things in Redis, and money and personal data nowhere.