Do the work once, answer many times
A request that arrives twice should only be expensive once, and every layer between a visitor and your database exists to make that true. The browser holds assets it was told it may keep, under instructions carried in the Cache-Control header. An edge network such as Cloudflare or Fastly holds copies in data centres near the visitor. On the origin, full-page caching built into nginx or sitting in front of it in Varnish returns finished HTML without ever waking PHP. Behind that, an object cache in Redis or Memcached stores query results and rendered fragments so the application stops asking Postgres or MySQL the same question all day. Each layer that answers leaves every layer behind it idle, which is why a properly cached site on modest shared hosting will outrun an uncached one on far larger hardware. The boundary of all this is personalisation. Anything that differs per visitor, a basket, a name in the corner, a price after a customer discount, has to be excluded or handled with care, and the header that announces it is Cache-Control with the value private. Everything else is fair game, and on most sites everything else is the overwhelming majority of what gets requested.
What goes wrong is almost never the storing. It is the forgetting. A cached answer is a standing claim that nothing has changed, and each layer needs its own story for the moment that claim stops being true. Three tools cover nearly all of it: a time-to-live sized to how much staleness the content can tolerate, an explicit purge fired when something is published, and versioned filenames for assets, which sidestep the problem entirely because a changed file is simply a different address. Skip the purge and you generate the support ticket every host receives daily, the one that begins with an editor saying they updated a page and nothing happened. Diagnose those from the visitor inwards, because the layers fail in a predictable order: a hard reload clears the browser, the edge is the next suspect, and the origin page cache is usually the guilty party on a CMS whose plugin never learned about a custom publishing workflow. The quieter failure is worse. Store a response that was built for one signed-in person in a cache that several people share, and you will hand somebody else's account page to a stranger. That is why sensible configurations keep authenticated routes out of shared caches by rule rather than by hope.
