Scale and performance

How the site absorbs a viral article, a live game and sustained traffic: prerendering, cache profiles, live polling, front-end budget.

2026-11-07

Scenarios

Four load shapes stress the Euro League site in different ways, and the site handles each with a different mechanism. The important thing is that they are different — reaching for the CMS cache to solve a live-scores problem, or for a live poll to solve a traffic problem, is how systems fall over. This section maps each scenario to its mechanism; the following sections go deeper on the caching and live-data paths and on front-end performance.

Scenario 1 — a viral article seen by millions

A single article is shared everywhere and takes a spike of readers at once.

Mechanism: full-route static + tag invalidation. Article detail pages are on-demand static — no searchParams, cached fetches only — so Next's full-route cache serves the rendered HTML from the edge/instance cache without re-rendering or re-querying Contentful per request. A million reads of the same article are a million cache hits against one baked page. When an editor publishes a correction, the /api/revalidate webhook expires that article's slug tag (content:articlePage:{slug}) and the next request re-bakes it once. The spike costs essentially nothing beyond serving bytes, and freshness is still immediate on publish.

Detail pages wear only their slug tag, so one article's publish does not drag every other article's page out of cache — see Caching at scale.

Scenario 2 — a game updating every few seconds

A live game: the score, the clock and the box score change every few seconds while it is being played.

Mechanism: the browser polls an uncached live endpoint; the page stays static. Live data does not go through Contentful, the use cache layer or the revalidation webhook. The schedule, team, player and home-hero cards are rendered once on the server — scheduled or final — and a small client island polls the app's own /api/live/games, which forwards to the Competition Engine's /games with the visitor's ETag, at the interval the engine asks for (next_poll_seconds), and only while a game on the page could be live. The Game Centre does the same against /api/games/live. Every visitor gets the same cacheable page; only the answer to the poll moves.

Trying to serve live scores through revalidation would mean invalidating and re-baking a page every few seconds, across every reader, for the length of a game — the opposite of what the cache is for. See Live data.

Scenario 3 — the Competition Engine's data pages

Teams, players, coaches, standings, stats and the schedule come from the Competition Engine (@v4/competition-engine), not from Contentful.

Mechanism: a use cache boundary with TTL classes. Every engine read goes through cachedEngineGet, tagged ce plus ce:competition:*, ce:season:*, ce:team:*, ce:player:*, ce:coach:* or ce:game:* (CE_TAG), and lived by one of the CE_LIFE classes in packages/competition-engine/src/tags.ts:

Class Used for Revalidate Expire
structural seasons, filters, teams, rosters, profiles, coaches 300s 3600s
computed standings, leaders, stats tables, game logs 300s 3600s
games the schedule 60s 3600s
gameDetail one game until it is played 60s 3600s
played a finished game 300s 3600s

Nothing emits the ce:* tags yet, so engine data expires by its class; the tags are the names the ingester's post-run revalidation will use. The live endpoints are never server-cached (noStore). A read gives up after ENGINE_TIMEOUT_MS (10 seconds), and a data area whose engine read fails renders its unavailable state (guardEngine, packages/basketball-app/src/lib/engine-failure.tsx) rather than the site's error page.

Scenario 4 — sustained high traffic

Not a single viral URL but a heavy, steady load across the whole site — a busy matchday across news, listings and content pages.

Mechanism: static prerender + a shared Redis tag manifest across instances. Because detail pages, content pages, the home page and listings are all statically prerenderable (pagination is path-based, so /news and /news/page/2 are individually cacheable static routes rather than dynamic renders; live addresses carry the locale segment, /en/news/page/2), the steady-state cost is serving cached HTML, and the app scales horizontally by adding replicas. Across those replicas the environment's Redis use cache handler (apps/web/use-cache-handler.mjs) shares the tags manifest — the invalidation stamps — so a webhook that lands on any instance is seen by all of them and no replica serves stale content. Entry storage stays per instance. Euro League runs on Flightcontrol (hosting.platform: "flightcontrol" in apps/web/club.config.ts) with its own ElastiCache Redis (CACHE_REDIS_URL). See Caching at scale.

The one-line summary

Scenario Mechanism
Viral article, millions of reads Full-route static HTML + slug-tag invalidation
Live game Client polling of an uncached live endpoint (engine ETag + next_poll_seconds)
Competition Engine data pages use cache boundary, ce:* tags, CE_LIFE TTL classes
Sustained high traffic Static prerender + shared invalidation across instances

Caching at scale

Scaling reads on the site is mostly a caching story, and the caching mechanics are documented in full in the Caching & revalidation guide. This section does not repeat them — it explains why those mechanics scale, and the properties that matter when load is high.

Static prerender is the foundation

The cheapest request is one that renders nothing. Every high-traffic surface is built to be statically served:

  • Detail pages (article, content page) and the home page are on-demand static — no searchParams, cached fetches only — so the full-route cache serves rendered HTML.
  • Listings are static too, because pagination is path-based: /news/page/2 is an individually cacheable route, page 1 is the bare /news, and /news/page/1 redirects to it. The high-traffic page-1 case is static. (Live addresses carry the locale segment: /en/news/page/2.)
  • Addresses not listed at build — every team, player and game page, an article published since the deploy — are served their shell on the first visit and upgraded to a cached page in the background (the ISR note beside cacheComponents: true in apps/web/next.config.ts).

The rule that keeps this true: a listing surface must not read searchParams for anything bounded — that silently makes the page dynamic and turns the busiest page on the site into a per-request render. Bounded navigation is a path; only genuinely combinatorial state falls back to a client fetch against a data endpoint with a static shell. This is covered in the Pagination & filtering guide.

Tags make invalidation precise, not broad

Under load, the danger is not staleness — it is over-invalidation: a single publish dumping the whole cache and forcing a re-render storm. The tag matrix (single source: packages/content-app/src/lib/cache.ts, CONTENT_CACHE) is designed against that:

  • Detail queries wear only their slug tag (content:articlePage:{slug}), never the coarse content:articlePage tag. If they wore both, one article publish would expire every article's cached page at once — a stampede. The accepted trade is that a detail page's related-content rail catches up within its revalidate window rather than instantly.
  • Listings keep the coarse tag and refresh immediately, as they must — a new article has to appear in the news list at once.

So a publish invalidates the smallest correct set of pages, and the re-bake cost stays proportional to what actually changed.

Cache profiles: stale-while-revalidate, then a floor

Each surface names a cacheLife profile (CACHE_PROFILE in packages/content-app/src/lib/cache.ts), in seconds:

Profile Surfaces Stale Revalidate Expire
editorialDetail article and content-page detail 60 300 3600
editorialListing home, listings, rails 30 120 1800
slowVocab categories, teams, players, sitemap reads 300 3600 86400
chrome site chrome (navigation, settings) 60 300 86400

Two things make these scale:

  • Stale-while-revalidate is the point. On a mass tag eviction a listing serves its stale copy and rebuilds once in the background, rather than blocking every concurrent request on its own origin query — the "Six Nations" lesson, where a mass eviction under traffic turned into a thundering herd.
  • The revalidate window is a floor, not the freshness mechanism. The webhook expires tags on demand (revalidateTag(tag, { expire: 0 }) in apps/web/src/app/api/revalidate/route.ts); the profile only bounds staleness if a webhook is ever lost, so the site self-heals without anyone noticing.

The Competition Engine's data has its own classes (CE_LIFE) — see Scenarios.

Redis across instances

Horizontal scaling adds replicas, and revalidateTag() only invalidates the instance that runs it. Euro League runs on Flightcontrol with its own ElastiCache Redis (CACHE_REDIS_URL, a runtime fromService binding). The use cache handler, apps/web/use-cache-handler.mjs, is wired through the cacheHandlers map in apps/web/next.config.ts (the singular cacheHandler option is never consulted under cacheComponents). It keeps entry storage in the built-in per-instance handler and puts the tags manifest in Redis, so one webhook's revalidateTag reaches every replica within a request.

The full mechanics — and the hard rule that each environment gets its own Redis, never one shared with anything else — are in Self-hosting: the Redis cache handler and the Deployment guide. The Cloudflare edge in front of the app is covered in Edge caching and purge.

The handler also fails safe: no CACHE_REDIS_URL, or any Redis error, and it behaves as the plain per-instance handler — a slowdown, never a thrown request. A reconnect cooldown stops it hammering Redis during an outage.

Load-testing a mass eviction

scripts/loadtest/eviction-storm.mjs reproduces a mass tag eviction under concurrent traffic. Run it against a real staging deployment with Redis wired — never the dev server or CI:

BASE_URL=https://staging.example.com \
CONTENTFUL_WEBHOOK_SECRET=<the staging secret> \
node scripts/loadtest/eviction-storm.mjs \
  --paths=/,/news,/news/category/first-team --concurrency=100 --rounds=3

It warms the pages, then repeatedly evicts content:articlePage and storms them at once, reporting p50 / p95 / max latency and the error rate per round, and exits non-zero when p95 or the error rate exceeds its budget. Take a warm-cache baseline first with --no-evict. The script's README is scripts/loadtest/README.md; pass paths with the /en locale segment for this site.

What does not scale through this path

Live, fast-moving data (scores, clocks, the box score) is deliberately kept out of the cache entirely — caching a value that changes every few seconds is pointless churn. That is the subject of the next section.

Live data

Some data changes far too fast to cache. A live game moves its score, clock and box score every few seconds for two hours. This data does not go through Contentful, the use cache layer or the revalidation webhook. The page is rendered once and cached like any other; a small client island polls an uncached endpoint on the app and patches the moving parts in place.

The source is the Competition Engine (@v4/competition-engine). Its origin stays private: the browser only ever talks to the app's own API routes, which forward to the engine per request.

Why live data bypasses the cache

The caching system exists to serve content that is stable between publishes and invalidate it precisely. Live scores have the opposite profile:

  • The cache is the wrong tool. Caching a value that changes every few seconds means invalidating and re-baking a page continuously, for every reader, for the whole game — churn that never earns a cache hit.
  • Contentful is not the source. Live data comes from the engine, not from editors publishing entries.
  • The page should not move, only the score. Every visitor gets the same cacheable page; the server renders every game card once — scheduled or final — and the browser swaps a live score and clock into the cards the scoreboard says are being played.

The scoreboard: /api/live/games

The schedule, team and player pages and the home hero render their game cards on the server and hand a client island a seed — the competition and, per game, its code, tip-off time and whether it is already played (LiveSeed in packages/basketball-app/src/lib/live-scores.ts). The hook, useLiveScores (use-live-scores.ts beside it):

  • polls only while a game on the page could be live — shouldPoll: not yet played, and tipping off between 24 hours ahead and 5 hours ago;
  • asks /api/live/games?competition=<E|U|J>&date=<YYYY-MM-DD> for today's games (UTC day), sending the last ETag as If-None-Match;
  • waits as long as the engine says (next_poll_seconds in the answer, 30 seconds if it says nothing, never less than 5);
  • stops while the tab is hidden and resumes on visibilitychange;
  • never throws: a failed poll waits and asks again.

The route, apps/web/src/app/api/live/games/route.ts, forwards to the engine's /games with cache: "no-store", passes the visitor's ETag up and the engine's ETag and Cache-Control back (a 304 costs nothing), and answers the camelCase scoreboardView. With no competition named it asks for the default competition's games explicitly. An unknown competition or a malformed date is a 400; an engine that is down or errors is a 502. livePatches and patchGames turn the answer into card changes — a scheduled game changes nothing; a live one gets its score and a clock such as Q4 · 08:45 (OT for overtime).

The Game Centre: /api/games/live

A game's own page polls /api/games/live?season=E2025&game=4 for its moving parts — the game with its clock and score, the box score, the team stats, the plays and the score by minute — as one GameCentreSnapshot, and redraws the modules through the same mapping the server used. The route factory is createGameLiveRoute (packages/basketball-app/src/games/live-route.ts), mounted at apps/web/src/app/api/games/live/route.ts.

  • It polls from 30 minutes before tip-off until the game is final (nextGamePoll in game-centre-modules.tsx), at the engine's nextPollSeconds (never under 5 seconds), never while the tab is hidden.
  • The engine is read per request through noStore; a final game's parts are the record and read through the played cache class.
  • &period=N answers that period's box score and team stats instead, for the modules' Period controls.
  • A malformed parameter is a 400, a game the engine does not have a 404, an engine failure a 502.
  • &replay=<speed>&replayFrom=<ms> plays a finished game back as if live, only where GAME_CENTRE_REPLAY=on — never on production.

Neither route has segment config: the app runs with cacheComponents, which refuses it, and both read their request, so they run per request. /api/* is outside the edge's cache rule.

Nothing waits for motion

A live score lands in the DOM the moment the poll answers; motion only decorates the arrival, and never delays a data update. See the Motion guide.

Consequences for scale

  • A live game causes no cache invalidation: the pages around it stay cached and cheap throughout; only the poll answer moves.
  • Poll load is bounded by the engine's own cadence and its ETags — a 304 carries no body, and polling stops outside the live window and in hidden tabs.
  • The engine's non-live reads (schedule, standings, rosters) are cached with their CE_LIFE classes — see Scenarios.

The realtime seam is unused here

The config schema (@v4/template-config, from the template) has a features.liveMatch feature (realtime: "ably", in packages/template-config/src/schema.ts) for push-based live data. Euro League does not set it in apps/web/club.config.ts, and no realtime client ships: live basketball data is polled as described above.

Front-end performance

Caching keeps the server cheap; what the browser has to parse and fetch is a separate budget. A Lighthouse campaign on 25 Oct 2026 found the cost in three places — the theme CSS every page shipped, the style work it caused, and image preloads — and each now has a rule. The authoritative statement of the theme rules is .claude/rules/frontend/theme-variant-patterns.md ("The app emits its club theme in PARTS, and the global layer on :root"); this page explains them for someone working in apps/web.

The club theme is emitted in parts, per route

The component themes of a package are emitted only by the routes that render that package:

File (apps/web/src/theme/) Emits Loaded by
tokens.css.ts the global layer + the ghost-core and ghost-content themes with no stylesheet of their own (below) app/[locale]/layout.tsx (every page) and app/global-error.tsx
tokens-home-hero.css.ts the ghost-basketball themes the home hero's widgets use app/[locale]/layout.tsx
tokens-video.css.ts ghost-video themes the video layout, and the data-area layouts
tokens-basketball.css.ts every ghost-basketball theme the teams, players, coaches, games, standings, stats, code-of-conduct and news/[...path] layouts
competition-themes.css.ts EuroCup and NextGen overrides, on :root.<class> app/[locale]/layout.tsx

Each part calls createClubThemePart(tokens, { components }) from @v4/ghost-core/theme with a package's OWN_THEMES list (exported by its init.css). Before the split every page parsed the video and basketball themes — about 40 KB gzip of render-blocking CSS the article, home and news pages never used.

A route that renders a package must load its part. Without it the package's components declare no --ph__* variables and render unstyled, and nothing fails. When you mount a package's components on a new route, import its part in that route's layout; when you add a widget to the home hero, add its themes (and those of everything it composes) to HOME_HERO_THEMES. Check in the browser: every ph__*-theme class a page uses must have declarations in that page's stylesheets.

Component themes travel with their components (7 Nov 2026)

The parts above still put every component theme of a package into one render-blocking file — 2.4 MB on every page, the homepage rendering 76 of the 242 themes it shipped. Each precompiled package (ghost-core, ghost-content, ghost-video, ghost-basketball) now emits a component's theme defaults inside that component's own .precompiled.css, at :where(.ph__x-theme), and lists the names in @v4/<package>/self-emitted-themes. The app's parts pass that list to createClubThemePart as selfEmitted and leave those themes out unless the club overrides one. The file every page loads fell to 644 KB: the global layer and the handful of themes with no stylesheet of their own.

Nothing changes when you add a component: its theme rides with its stylesheet automatically, provided its component module imports that stylesheet (one that is never imported is left to the part). The precompile fails if a listed theme is not actually emitted. The full rule is in .claude/rules/frontend/theme-variant-patterns.md.

The header's box is held open while it streams in (7 Nov 2026)

The site header is a hole in the prerendered shell: each competition's header streams in after the shell (ELBWEB-584). While the shell's CSS was slow, the header always landed before the first paint; once the split above made the page paint about a second sooner, the page painted at the top and dropped 56–112px when the header arrived — a layout shift of 0.068 (mobile) and 0.08 (desktop) that cost the homepage three desktop points. The header's Suspense fallback is now an empty box the band's height (HEADER_RESERVE_HEIGHTS in apps/web/src/lib/header-variants.ts: 56, 108 from 768, 112 from 1440, measured on every section and competition). If the header's height changes, change those values with it.

The global layer is declared once, on :root

tokens.css.ts declares the club's global layer with globalTheme.createScoped(":root", tokens.global) and every element inherits it. Declared at the theme class every component root carries, each of the ~265 component roots on the homepage re-declared all ~7,600 global properties, and the browser resolved every one on every element — the bulk of the 684 ms of style work Lighthouse measured. The competition layers sit on :root.<class>, and the page measure's gutter is declared on :root too, because a formula leaf resolves where it is declared.

Before changing either, pixel-diff full pages at two widths: the old per-element re-declaration reset a subtree's inherited globals, and anything that relied on that would only show up there. Storybook and the e2e still emit at the class; these are the app's choices.

The app is the one emitter, and component styles are precompiled

apps/web/next.config.ts sets PH_THEME_EMISSION=club. With it, each package's createPackageTheme only registers its themes (still validating its own-theme list) and the club parts above emit each theme once with the club's values, rather than every package bootstrap shipping the Ghost defaults again under the same selectors. Storybook and the e2e never set it.

Each component package's build also precompiles its vanilla-extract stylesheets (packages/ghost-core/scripts/precompile-styles.mjs, run by the build scripts of ghost-core, ghost-content, ghost-video and, since 7 Nov 2026, ghost-basketball; a style helper — a stylesheet exporting factories that only other stylesheets import — is bundled by its users and left as tsc wrote it): the app imports plain .precompiled.css files and class-name modules, and only init.css and what it imports stays raw for the app's vanilla-extract unit. This is mainly a build-memory fix — the plugin used to evaluate the whole theme graph once per stylesheet — and it carries one rule: a stylesheet, a .theme.ts or anything they import must never reach a package barrel or a family's .tsx. The precompile fails the build when one does.

Because the app consumes package dist, a change to a component package is not visible in apps/web until that package is rebuilt — see Installation.

Pictures load lazily unless they are the page's main picture

cardImage in packages/content-app/src/lib/article-cards.tsx renders every slot picture loading="lazy", decoding="async" by default. React hoists a <link rel=preload> for every picture that is not lazy, and the homepage was queueing 69 image preloads ahead of its own hero. The page's main picture — a hero, a listing header, a profile's photograph — passes { priority: true } and is fetched eagerly with fetchPriority="high". Pictures also carry the crop's width and height to reserve their box, and a srcSet / sizes built from the surface's image request — see Images & focal points.

When you add a surface: pass priority to exactly one picture, the one above the fold that the page is about.

Load testing

The server side of a traffic spike is exercised by scripts/loadtest/eviction-storm.mjs — see Caching at scale.