×marble
all posts
Jun 15, 2026·11 min read

Cache Invalidation in Personalization Systems: The Hardest Problem

Cache invalidation in personalization is the hardest problem in computer science wearing a personalization hat. The patterns that work in production.

Alex Shrestha·Founder, ×marble

Cache Invalidation in Personalization Systems: The Hardest Problem

TL;DR.

  • Cache invalidation in personalization systems is harder than general caching because every key is a per-user variant, and the underlying features change asynchronously across user actions, item updates, and model retrains.
  • Naive TTL caching breaks personalization in two ways at once: latency, because the cardinality is users × items, and freshness, because a 60-second stale recommendation is a different product than a real-time one.
  • The pattern that works in production is layered: short TTLs for ranked outputs, event-driven invalidation for user-feature caches, write-through for item-feature caches, and write-around for the long tail.
  • CDN, app cache, and feature-store cache must agree on the same invalidation event or you get torn reads — three caches showing three versions of the same user.
  • Cache warming is mandatory, not optional. Pinterest's feature cache takes 10 minutes to an hour to warm a cold node; you cannot afford that hit during a deploy.

The Phil Karlton quote is overused, but it earns its keep in personalization. Cache invalidation in personalization systems is the hardest problem in computer science wearing a personalization hat — every cache key is a per-user variant, every input changes asynchronously, and every staleness window has a different cost depending on the surface. This post is about the patterns that actually work when you have to ship a personalized product that is fast, fresh, and not on fire.

We will cover why the textbook patterns (write-through, write-behind, write-around, TTL, event-driven) need to be combined per use case, what the three-tier cache topology looks like in a modern personalization stack, how to coordinate invalidation across CDN, app cache, and feature-store cache, and what to do about cold starts.

Why personalization breaks naive caching

A standard web cache stores URL -> HTML. The cardinality is bounded by the number of pages. A personalization cache stores (user_id, surface, model_version, candidate_set_hash) -> ranked_output. The cardinality is bounded by users × surfaces × model versions × time — which is to say, it isn't bounded at all in any useful sense.

This breaks the assumptions behind every caching tutorial you have read. Personalized cache keys have low reuse: a given key may be hit twice in its TTL window and then never again, because the user's session ends. The hit rate of a per-user output cache is structurally bounded by the average session length divided by the TTL, not by the size of the cache. You can throw a hundred terabytes of Redis at this and the hit rate barely moves.

The second break is that the inputs to the cached value change on independent clocks. A ranked recommendation is a function of: the user's profile (changes on every interaction), the item catalog (changes on merchandiser actions), the candidate retriever (changes on model retrain), and the ranking model itself (changes on deploy). Any of those events can invalidate any ranked output. If you only TTL on time, you serve stale recommendations for the entire TTL window after a relevant event fires.

The third break is multi-tier: a personalized response is assembled from a CDN edge cache, an application cache (Redis / Memcached), and a feature-store cache (Cachelib / DynamoDB DAX / in-process). Each tier has its own TTL and its own invalidation channel. If those channels disagree, you get torn reads — three caches showing three versions of the same user.

TTL versus event-driven invalidation

The textbook framing is TTL versus event-driven; the production reality is that you need both, on different objects. Time-based invalidation removes items even if they have not changed, which causes unnecessary backend traffic; event-based invalidation triggers cache updates immediately when underlying data changes but requires additional event-detection infrastructure, per Stellate's caching writeup. You pay a different tax for each, and the choice depends on the cost of staleness.

A useful decomposition: classify each cached object by staleness tolerance and invalidation event volume.

  • Low staleness tolerance, low event volume — for example, account-level entitlements or feature flags. Use event-driven invalidation; subscribe to a change-data-capture (CDC) stream from your authoritative store.
  • Low staleness tolerance, high event volume — for example, the user-feature vector that drives ranking. Use write-through caching from the feature pipeline; the cache and the store update atomically.
  • High staleness tolerance, low event volume — for example, item embeddings refreshed on a nightly model retrain. Use TTL keyed to the retrain cadence (24 hours plus a jitter), with a forced bust on deploy.
  • High staleness tolerance, high event volume — for example, "users who viewed this also viewed" aggregate signals. Use TTL with stale-while-revalidate; serve the stale value and refresh asynchronously.

The single most expensive mistake we see is using one TTL for everything. A 60-second blanket TTL means your account-flag changes take 60 seconds to propagate (too long for billing) and your view counters update 60 times more often than necessary (wasted load). Different objects need different policies. This is also why a per-namespace TTL configuration — what Pinterest implemented when they extended Cachelib with namespaces, per Pinterest Engineering — is a non-negotiable design property.

Write-through, write-behind, write-around — by use case

The three write-side cache patterns apply to personalization, but with sharper trade-offs than the textbook gives. Here is when each one earns its keep.

Write-through writes to the cache and the source of truth in the same transaction. Use it for user-feature caches: when a click event updates the user's profile vector, the feature store and its cache should be consistent. The cost is latency on the write path — every click pays a cache write — but you eliminate the read-after-write inconsistency that destroys session-level personalization. If a user just clicked "I am vegetarian" and the next page still shows steakhouses, the cache is the bug.

Write-behind (also write-back) buffers writes in the cache and flushes asynchronously. Use it for interaction logs and counters: impression counts, view counts, dwell-time aggregates. These are write-heavy, the consumer is downstream batch jobs, and a small bounded delay is acceptable. Do not use write-behind for anything that drives the next ranking call — buffered writes mean session features lag the session.

Write-around writes to the source of truth and skips the cache; the cache is populated on read. Use it for long-tail items: the 99% of catalog entries that are written daily by merchandisers but read rarely. Loading every write into a hot cache pollutes the working set with cold keys. Read-time population fills the cache only with what is actually requested. Pair this with a small LRU and your tail latency drops without your hit rate cratering.

The pattern table for personalization specifically:

| Cached object | Pattern | TTL | Invalidation | |---|---|---|---| | User-feature vector | Write-through | None (event-driven) | CDC from feature pipeline | | Item embedding | Write-around | 24 h | Forced bust on model deploy | | Ranked candidate list | TTL + SWR | 30 - 300 s | Event bust on profile delta | | Item metadata (title, price) | Write-through | 5 m | CDC from catalog | | Aggregate signals (popularity) | Write-behind | 5 m | Periodic recompute | | Account flags / entitlements | Write-through | None | CDC from auth store |

Coordinating CDN, app cache, and feature-store cache

A personalized response is rendered by a stack. The CDN holds the shell, the app cache holds the ranked output, and the feature-store cache holds the inputs to the ranker. Each tier has its own TTL and its own invalidation channel. If they disagree, the user sees a stale recommendation rendered over a fresh layout, or a fresh recommendation rendered without a freshly-promoted item — both look broken.

The trick is to make one event the single source of truth for invalidation. We use a model where every personalization-affecting event publishes to a single bus (Kafka, in our case), and every cache tier subscribes:

  1. The CDN keys on (surface, locale, version). Personalization is rendered client-side or via edge-personalization. The CDN does not cache the user-specific payload; it caches the shell and the personalization is composed at the edge.
  2. The app cache keys on (user_id, surface, model_version). It subscribes to the bus and invalidates on profile-delta and model-deploy events.
  3. The feature-store cache keys on (entity_id, feature_group). It subscribes to the same bus and invalidates on feature-pipeline updates.

This is the same pattern that Pinterest uses with namespaces and eviction domains in Cachelib — each entity-feature group has its own TTL and its own invalidation lifecycle, but they all listen to one upstream stream. The architectural property we are buying is invalidation atomicity across tiers. If you publish one event, all three tiers eventually catch up. There is a brief inconsistency window — we measure it in single-digit seconds — but it has a bound and it is observable.

We have written about how this composes with the rest of the stack in our reference architecture for real-time personalization. Cache invalidation lives downstream of ingestion and upstream of ranking; it is not optional infrastructure.

Cache warming and the cold-start tax

Cold caches in a personalization system are not the same as cold caches in a content site. When a content-site cache cold-starts, latency spikes but hit rate climbs to steady-state within seconds because traffic is concentrated on a small head. Personalization caches are long-tail by definition — most keys are touched once per session — so the hit rate climbs much more slowly and may never reach the same steady-state value.

Pinterest reports that a cold feature cache takes 10 minutes to an hour before sufficient hit rates are achieved, per their engineering blog. That cold window includes higher tail latencies, more pressure on the feature store, and a feedback loop where slow feature reads delay candidate generation, which delays the next request, which keeps the cache cold for longer. On a deploy, you cannot afford this.

Two patterns work in production:

  1. Snapshot-and-restore: persist a cache snapshot to disk or object storage, and load it on warm-up. Works for long-TTL feature caches where the snapshot is still valid by the time the node is up.
  2. Traffic replay: replay a few minutes of recent production traffic against the cold node before sending it live traffic. Necessary for short-TTL caches where a snapshot would be stale by the time it loaded. Pinterest favors traffic replay for real-time features for exactly this reason.

Both are infrastructure investments. Neither is something you bolt on the week before launch. If you are building a personalization layer, plan for them in week one — your day-zero personalization story depends on them, as we covered in our piece on the cold-start problem.

How ×marble fits in

×marble is a personalization knowledge-graph product, which means caching is core to what we ship. The graph layer caches entity neighborhoods (write-through, event-driven), the embedding layer caches vectors (write-around, retrain-busted), and the ranking layer caches ordered candidate lists (TTL + stale-while-revalidate). The whole stack listens to one event bus, so an interaction update propagates to all three tiers without us writing per-tier wiring. We did not invent this — Pinterest, Netflix, and Spotify all run variants of it — but we packaged it so you do not have to.

If you are building a personalization system from scratch and the cache topology described above is the part that scares you, that is the part we want you to skip. Look at ×marble, and at our consumer surfaces — Vivo for daily AI briefings and marblexmusic.com for music personalization — for what the output looks like.

FAQ

Why is cache invalidation in personalization systems so hard?

Because personalization caches are keyed per user, the cardinality is unbounded and hit rates are structurally low. On top of that, the inputs to a cached recommendation (user profile, item catalog, candidate retriever, ranking model) all change on independent clocks. Any of those changes can invalidate any cached output, so a single TTL is never sufficient.

What's the best cache invalidation strategy for recommendation engines?

There is no single best strategy. Combine four patterns: write-through for user-feature caches, write-around for item embeddings, TTL with stale-while-revalidate for ranked outputs, and event-driven invalidation for entitlements and account flags. Tune the TTL per cached object class, not globally.

How long should the TTL be for personalized recommendations?

For ranked output caches, 30 to 300 seconds is the typical range. Below 30 seconds, your hit rate is too low to be worth the cost; above 300 seconds, you serve recommendations that ignore the user's last few interactions. The exact value depends on session length on your surface and how quickly the user reacts to a stale set.

Should I use write-through or write-behind caching for personalization?

Use write-through for anything that drives the next ranking call — user-feature vectors, profile deltas, entitlements. Use write-behind for write-heavy interaction logs and counters where a small bounded delay is acceptable. The session-time test: if a buffered write would cause the next request in the same session to see stale data, use write-through.

How do I coordinate cache invalidation across CDN, app cache, and feature store?

Publish every personalization-affecting event to one bus (Kafka, Pulsar, etc.) and have each cache tier subscribe. Key each tier independently — CDN on (surface, locale, version), app cache on (user_id, surface, model_version), feature store on (entity_id, feature_group) — but converge them on the same upstream event stream. You get invalidation atomicity with a single bounded inconsistency window.

Further reading

the product behind these notes

×marble is the personalization graph.

One API. A living knowledge graph per user. Day-zero ready, explainable by construction. We built it so you don't have to.

See ×marble