RZ

Preparing your experience

Back to Blog
System Architecture

Combining ISR and Real-Time Personalization Without the Performance Tax

December 1, 20248 min read
Next.jsRedisPerformanceArchitecture

I built NewsHub as a personal project specifically to answer a question I kept running into at work: how far can Next.js's Incremental Static Regeneration actually go for content that also needs to feel personalized to each visitor? The two goals normally pull in opposite directions. ISR wants pages to be mostly static and cheap to serve, generated once and reused across many visitors. Personalization wants computation tailored to the individual visitor, ideally on every request. Getting both at once looked, on paper, like it required picking one and compromising on the other.

Why Not Just Render Everything Per-Request?

The simplest approach to personalization is also the most tempting one to reach for first: compute the entire page fresh on every request, so every element — including the article content itself — can, in principle, be tailored to the visitor. The problem is that this approach throws away everything that makes ISR attractive in the first place: cache hits, cheap horizontal scaling, and fast time-to-first-byte for the vast majority of a page's content that doesn't actually need to be personalized at all.

For a news site specifically, this matters a lot, because most of a page's content — the article body itself — is the same for every reader. Only the ranking and selection of which articles to show on the homepage genuinely benefits from personalization. Recomputing the entire article page per request to personalize a homepage feed ranking would mean paying a full-render cost on every load, for a benefit that only applies to a small part of the page.

The Split

The architecture splits cleanly along exactly that line: what actually benefits from personalization, and what doesn't.

Article Pages: ISR with a Short Revalidation Window

Article pages use ISR with a short revalidation window — long enough to serve most requests from cache, short enough that the content still feels current for a news site where "stale for an hour" is a real, noticeable problem. This is standard ISR usage, and it's exactly the right tool here because article content genuinely doesn't need per-visitor personalization.

// pages/articles/[slug].js
export async function getStaticProps({ params }) {
  const article = await fetchArticle(params.slug);
  return {
    props: { article },
    revalidate: 60, // seconds — fresh enough for news, cheap enough to cache
  };
}

export async function getStaticPaths() {
  return { paths: [], fallback: 'blocking' };
}

Homepage Feed Ranking: A Background-Computed Redis Sorted Set

The part that actually needs personalization — which articles appear, and in what order, on a given user's homepage — is handled entirely differently, and deliberately not on the request path. A background worker continuously scores articles into a Redis sorted set per user segment, weighted by recency and engagement signals (what similar users have read, clicked, and spent time on). The homepage API then simply reads a slice of that pre-computed sorted set at request time — no scoring logic runs while a user is actually waiting for the page to load.

// Background worker (runs continuously, off the request path)
async function updateFeedScores() {
  const articles = await getRecentArticles();
  for (const article of articles) {
    const score = computeEngagementScore(article); // recency + engagement signals
    await redis.zadd(`feed:${article.segment}`, score, article.id);
  }
}

// Request-path handler (fast — just a read)
async function getHomepageFeed(userSegment) {
  return redis.zrevrange(`feed:${userSegment}`, 0, 19); // top 20, pre-ranked
}
The expensive part of personalization — scoring potentially thousands of articles against engagement signals — happens once, asynchronously, regardless of how many users are about to load the homepage. The cheap part — reading a slice of an already-ranked list — happens on every request. That's the inverse of what a naive "personalize everything live" implementation would do, where the expensive computation happens once per visitor instead of once per update cycle.

Why This Split Matters More As Traffic Grows

The naive per-request personalization approach gets linearly more expensive as traffic grows — every additional visitor is another full computation. The split architecture here doesn't: the background scoring job's cost is a function of how many articles need scoring and how often, completely decoupled from how many users are reading the homepage at any given moment. Ten visitors or ten thousand visitors read from the same pre-computed sorted set at effectively the same cost per request.

Segmenting Without Over-Personalizing

A genuinely per-user ranking, recomputed for every individual visitor, would reintroduce the exact scaling problem this architecture avoids. Instead, users are grouped into segments based on coarse behavioral similarity, and the sorted set is computed per segment rather than per individual. This trades a small amount of personalization precision for a large reduction in computational cost — a trade that, in practice, is barely noticeable to users but meaningfully changes the system's cost profile.

Best Practices

  1. Identify which part of a page actually needs to vary per visitor before reaching for per-request rendering everywhere — most pages have far less genuinely personalized surface area than it first seems.
  2. Move expensive, personalization-driving computation off the request path entirely, into a background process that updates on its own schedule.
  3. Segment rather than fully individualize personalization when the marginal benefit of per-user precision doesn't justify the computational cost.
  4. Keep ISR's revalidation window tuned to how quickly staleness actually becomes noticeable for your content type, not an arbitrary default.

Common Mistakes

  • Assuming personalization requires abandoning static generation entirely, rather than isolating which specific page regions actually need it.
  • Running expensive ranking or scoring logic synchronously in the request path, so every page load pays for computation that could have happened once, in the background.
  • Over-personalizing to the individual user when segment-level personalization would deliver nearly the same perceived relevance at a fraction of the cost.
  • Setting an ISR revalidation window based on a generic default rather than the actual content type's real staleness tolerance.

Performance Comparison

Approach Per-request cost Personalization freshness
Full per-request rendering High — grows with traffic Immediate
Pure ISR, no personalization Very low None
Split architecture (this approach) Low — flat regardless of traffic Near-immediate, segment-level

FAQ

Q: Doesn't segment-level personalization feel less relevant than true per-user personalization?
In practice, the perceived difference is small for a content-ranking use case like a news feed, where "articles similar users engaged with" captures most of the relevant signal. True per-user ranking matters more for something like product recommendations with a smaller, higher-stakes catalog — a different problem than ranking a continuously refreshing stream of news articles.

Q: How often does the background worker actually update the sorted set?
Frequently enough that the homepage feed feels current — on the order of every few minutes — but completely decoupled from request volume, so a traffic spike never triggers extra scoring work.

Q: Could this pattern apply outside of a news site?
Yes — I've since used this same split as a reference pattern whenever a project needs both static-content performance and some form of request-time personalization: identify the small personalized surface, move its computation off the request path, and let the rest of the page stay cheap and cacheable.

Handling Cold Starts for New Segments

A subtlety this architecture has to handle: what happens when a new user segment appears, or an existing segment's sorted set hasn't been populated yet because the background worker hasn't run since a new segmentation boundary was introduced? A naive implementation would return an empty or error state for the homepage feed in that window, which is a poor first impression for exactly the users a personalization system is trying to serve well.

We handle this with a fallback tier: if a segment's sorted set is empty or hasn't been updated within an expected freshness window, the homepage falls back to a global, non-personalized ranking rather than an empty or broken page. This fallback is cheap to compute — it's effectively the same pre-computed pattern, just keyed globally instead of per segment — and it means a cold-start condition degrades gracefully to "reasonably relevant, not personalized" rather than failing outright.

Cache Invalidation for Article Updates

ISR's revalidation window handles routine staleness gracefully, but breaking news occasionally needs an article correction or update to propagate faster than the standard 60-second window allows — a factual correction sitting stale for a minute is a real, if minor, editorial problem for a news product specifically. Next.js supports on-demand revalidation for exactly this case: an editorial action (publishing a correction) triggers an explicit revalidation call for that specific article's path, bypassing the timed window entirely for that one update.

// Triggered by an editorial "publish correction" action
export default async function handler(req, res) {
  await res.revalidate(`/articles/${req.body.slug}`);
  return res.json({ revalidated: true });
}

This combination — timed revalidation for routine freshness, on-demand revalidation for exceptional cases — covers both the common case and the edge case without requiring every article update to bypass the cache, which would have reintroduced the exact per-request cost the ISR approach was chosen to avoid.

Conclusion

Article pages serve from cache with sub-second loads while staying within a minute of live. The personalized homepage feed renders from a pre-computed, segment-level ranking with effectively zero per-request scoring cost. Getting both ISR's performance characteristics and genuine personalization wasn't a matter of choosing one over the other — it was a matter of correctly identifying which small part of the page actually needed personalization, and refusing to let that requirement dictate the rendering strategy for the rest of the page.

Found this useful? Explore more articles on System Architecture.

All articles