Scale and performance
How the site absorbs a viral article, a live game and sustained traffic: prerendering, cache profiles, live polling, front-end budget.
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/2is an individually cacheable route, page 1 is the bare/news, and/news/page/1redirects 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: trueinapps/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 coarsecontent:articlePagetag. 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 })inapps/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 asIf-None-Match; - waits as long as the engine says (
next_poll_secondsin 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
(
nextGamePollingame-centre-modules.tsx), at the engine'snextPollSeconds(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 theplayedcache class. &period=Nanswers 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 whereGAME_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_LIFEclasses — 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.