“Hello, Ivan” in the header. That is what broke the full-page cache on one project: either the whole page became uncacheable, or Ivan’s greeting was served to strangers. We managed both, in the same month.
Full-page cache behind a reverse proxy is a great first step. It dies on the first personalized element.
The way out is to stop thinking of the page as one object. It is regions with different speeds. The header with the user name changes per user. Navigation changes per deploy. The product list changes every few minutes. Prices and stock change every few seconds. Give all of that one TTL and you must pick the minimum, which means almost no cache at all.
Two working approaches.
Server-side composition, ESI being the classic. The proxy caches fragments separately, each with its own TTL, and assembles the page. Powerful, but your templates now know about the proxy, and debugging a page becomes debugging a distributed system.
Or cache the fully anonymous page and load the personal bits from the client. The header fetches “who am I” with one small request after render. For pages where the personal part is small, this is my default. The cacheable page stays trivially cacheable, the personal endpoint is cheap and honest.
One trap in both approaches: cache key explosion. Vary a fragment by language, then by currency, then by user segment, and you quietly multiply. Three dimensions of five values is 125 copies of every fragment, and the hit rate is gone. Count the variants before adding a dimension. If the number does not fit in your head, the cache will not fit in memory either.