Docs Hub Prerender route

lib-web-prerender

@vfuk/lib-web-prerender is a small, shared Express middleware published as an npm package and installed by Vodafone UK web shops. Its one job is to make pages appear instantly and be readable by Google, by keeping a pre-built copy of each page's finished HTML in a shared Redis cache and serving that copy instead of making every visitor's browser rebuild the page from scratch.

Speed

Returning a ready-made HTML page from Redis is far faster than asking the browser to download JavaScript, call APIs, and then draw the page.

🔍

SEO

Search engines get real HTML with real content and prices in it, not an empty shell that only fills in after JavaScript runs.

♻️

Shared, Not Copy-Pasted

Every Vodafone shop uses the same battle-tested caching logic. Fix a bug once, publish a version, all consumers benefit.

🛡️

Never Breaks The Page

Every failure path falls back to next() — the normal client-side render. A broken cache makes the site slower, never blank.

The one-sentence version

It is a cache in front of your web app that stores finished HTML pages, decides who is safe to serve a cached page to, and quietly refreshes pages in the background when they go stale.

At a glance

FactValue
Package name@vfuk/lib-web-prerender
Version at time of writing0.35.0
TypeExpress middleware library (not a running service)
LanguageJavaScript (ES modules, compiled by Babel)
RuntimeNode 18.20.5 (Volta pinned); CI builds on Node 22.14.0
Source files that matter~28 small single-purpose modules under src/
Hard requirementsA Redis client, and a URL for the external prerender service
Owner / contactBen Welsh (per README.md)
LicenceUnlicense (internal)

Plain-English Description

No code knowledge assumed. Read this section on its own and you will understand the repo.

The problem, using a restaurant

Imagine a restaurant where every single dish is cooked completely from scratch the moment a customer sits down. Chopping, simmering, plating — all of it, per customer. The food is fine, but everyone waits a long time, and the kitchen does the same work over and over for people who all ordered the same thing.

A modern Vodafone web page works like that. When you open a page, your phone or laptop downloads a big bundle of JavaScript, that JavaScript then phones several Vodafone systems to ask "what plans exist?", "what do they cost?", "what does the marketing team want shown today?", waits for all those answers, and only then draws the page. Meanwhile you are looking at a blank or half-finished screen.

What this repo does about it

This repo is the restaurant's hot counter. The first time somebody orders a dish, the kitchen cooks it properly and puts an extra portion on the hot counter. Everybody who orders that same dish for the next few minutes is served from the counter immediately. Every so often the portion on the counter is thrown out and replaced with a freshly cooked one, so nothing goes stale.

The plain-English flow
Visitor asks for a page
Is there a ready copy on the counter?
YES → hand it over instantly
NO → cook it normally this time, and stock the counter for next time

Six things it is careful about

1. Never serve the wrong person's page

A cached page is shared by everyone, so it must contain nothing personal. If the library can tell you are logged in or already shopping (you have a basket, a journey, a signed-in session), it refuses to use the cache and lets the site build your page fresh and personal.

2. Different pages are different dishes

A page for business customers is not the same page as one for consumers, even at the same web address. The library builds a "label" for each cached copy out of the address plus the specific bits of the web address and cookies the consuming shop declared as meaningful.

3. Freshness has a timer

Each cached page records when it was made. Past its expiry (10 minutes by default) the library still hands out the slightly-stale copy — nobody waits — while simultaneously ordering a fresh one in the background.

4. A new release throws away the old menu

When developers deploy a new version of a shop, cached pages from the old version point at files that no longer exist. Every cached page is stamped with a release ID, and any page with the wrong stamp is rejected and regenerated.

5. Google gets a special plate

Google's crawler gets an extra-safe version of the page with all the interactive scripts stripped out (structured data is kept). It is stored separately and lasts three days.

6. If anything goes wrong, the site still works

Cache unreadable? Prerender service down? Page half-built? In every one of those cases the library steps aside and lets the site render normally. Slower, but never broken.

What it is NOT

It is not a website, not a server you can visit, and not the thing that actually renders pages. The actual rendering is done by a separate prerender service (a headless browser) that this library talks to over HTTP. This repo is only the decision-making and caching layer that sits inside each shop.

Technical Description

For engineers. Assumes Express, Redis, and SPA/SSR concepts.

@vfuk/lib-web-prerender exports a single factory function as its default export. You call it once with an options object at server start-up, and it returns a standard Express middleware (req, res, next). It must be mounted before the middleware that performs client-side rendering, because its entire contract is "either I respond, or I call next()".

import prerender from '@vfuk/lib-web-prerender'

app.use(prerender({ redis, prerenderServerUrl, /* ...options */ }))
app.get('/*', clientSideRender)   // fallback renderer

Architecture

Browser / Googlebot
Express app
consuming web shop
prerender()
this library
Redis cluster
shared page cache
Prerender service
POST /render, headless browser
clientSideRender
next() fallback

Design characteristics

Published shape

yarn build compiles src/ with Babel twice: a modern build into lib/ and a legacy build into lib/legacy/. Tests and mocks are excluded. The middleware subtree is compiled with a Node 18.20 target; everything else targets browsers. package.json is copied into lib/, and lib/ is what gets npm published.

Why This Repo Exists

1. Client-side rendering is slow on first paint

Vodafone shops are JavaScript applications. Without help, a first-time visitor pays for bundle download, parse, execute, and a fan-out of API calls before seeing content. Prerendering removes all of that from the critical path for cache hits.

2. Commercial pages have to be indexable

Plans, pricing and marketing pages are acquisition surfaces. Crawlers must see prices and copy in the raw HTML. The dedicated Googlebot cache exists purely for this.

3. The logic is subtle and must not be duplicated

Deciding who may receive a shared page, how to key it, when it is stale, how to avoid stampedes, and how to invalidate on release is genuinely tricky. Re-implementing that in every shop would guarantee divergence and privacy bugs. One package, one implementation.

4. Each shop still needs to differ

Different shops care about different query parameters, cookies, auth cookie prefixes, cache durations and skip rules — hence the options object rather than hard-coded behaviour.

5. Personalisation must be respected

Logged-in and mid-journey users must never receive another user's HTML. The skip rules in initShouldSkipPrerender are the privacy boundary of the whole system.

Main purpose, stated once

Serve fast, cacheable, crawler-friendly HTML to anonymous visitors, and get out of the way entirely for everyone else.

Who Consumes It

The library is imported by Vodafone UK web shop applications. The clearest signal in the repo itself is .linkrc.js, which whitelists local linking only into projects prefixed web-shop-, and the README.md, which documents the integration using web-shop-simo as the worked example.

What a consumer must provide

  • A Redis client (usually an ioredis cluster)
  • A prerenderServerUrl pointing at the prerender service
  • A client-side render middleware mounted after this one
  • HTML containing the placeholder markers this library replaces
  • Optionally: hydrate() calls around its API requests

What a consumer gets back

  • Cache hits answered directly with X-Prerender-Cache: true
  • Background cache warming and refreshing
  • A script-free static variant for Googlebot
  • Captured API data inlined as window._hydratedData
  • Automatic cache invalidation on every release
Release model

Consumers pin a version in their package.json. Changes here are released by merging to main with a semantic-version keyword in the PR title, which publishes a new package version. Nothing changes for a consumer until they bump.

Quick Recap: The Problem It Solves

This whole group of pages is one thing: a real, worked-through explanation of prerender using web-shop-simo (the Vodafone SIM-only shop) as the living example — how it's switched on, how to see it working in a browser, what overrides it, what happens when a price changes, what Redis actually is, and how to test the new organisationId setting from PR #123.

One sentence, if you read nothing else

Without prerender, a visitor's browser downloads code and calls APIs before drawing the page — slow, and invisible to Google. With prerender, a finished copy of the page is built once and handed out instantly to the next visitor, while staying completely invisible to anyone who is logged in or mid-purchase.

Same idea, restaurant version
Visitor asks for a page
Is a ready copy on the hot counter?
YES → served instantly
NO → cooked fresh this time, counter restocked for next time

The 4 Pieces & Where They Run

Knowing which of these four things is "code you're looking at" vs. "a real server somewhere" clears up most of the confusion.

PieceWhat it actually isRuns where
lib-web-prerender An npm package. Just code — no server, no port of its own. Baked directly into web-shop-simo's own server process when it starts up.
web-shop-simo The actual Vodafone SIM-only shop (Express + React). Its own deployment, per environment.
Prerender Service A real, separate running server — a headless browser that builds the finished HTML. Its own deployment, reached over HTTP via PRERENDER_SERVICE_URL.
Redis A real, separate running database — the shared cache storage. Its own deployment, reached via PRERENDER_REDIS_URL / PRERENDER_REDIS_PORT.
The bit that trips people up

When you deploy web-shop-simo, this library isn't "a server you visit" — it's just logic running inside the shop's own Node process on every request. It calls out to the other two real servers (Prerender Service, Redis) when it needs to.

How web-shop-simo Turns It On

File: src/server/common/middleware/prerenderMiddleware/prerender.middleware.ts — the master switch lives right at the top of it.

// The master on/off switch — no PRERENDER_SERVICE_URL, no prerender at all
if (!process.env.PRERENDER_SERVICE_URL) {
  logger.group('prerender').yarn.info('Prerender disabled', 'no service url')
  return   // prerender middleware is never mounted — app runs with zero caching
}

app.use(
  prerender({
    redis: mockPrerenderRedis === 'true' ? new MockRedis() : connectRedis(),
    queryParamsToRetain: getQueryParamsToRetain(),
    cookiesToRetain: ['features', 'customerSegment'],
    cacheSeconds: 300,                                // 5 minutes before a background refresh
    skipChecker: (req) => req.cookies.basketId,        // extra belt-and-suspenders skip
    prerenderServerUrl,
    authCookiePrefix,
    buildHash: process.env.BUILD_BUILDID ?? 'fallback_hash',
    environment: process.env.ENVIRONMENT,
    ...
  }),
)

What each line means, plainly

redis

Where cached pages are stored — a real Redis, or a fake in-memory one for local dev.

queryParamsToRetain

E.g. segment matters, utm_source doesn't. Only listed params split the cache into separate entries.

cacheSeconds: 300

A page is "fresh" for 5 minutes, then it's served once more while quietly being rebuilt in the background.

skipChecker

If you already have a basket, you're mid-purchase — never show you someone else's cached page.

buildHash

Tags every cached page with the current app version (the CI build ID), so a new deployment automatically throws out old cached pages.

environment

Keeps different environments' caches from mixing if they ever shared one Redis.

Not wired up yet

As of today, web-shop-simo does not pass organisationId — that's the new setting from PR #123. It exists in the library, but this shop hasn't opted into it. See Testing The New orgId Flow.

Environments & The On/Off Switch

The single on/off switch is: is PRERENDER_SERVICE_URL set? Where that value comes from is different for local development vs. real deployed environments.

EnvironmentWhere the value comes fromValue
Local (checked-in .env files) .env/common.env, .env/local-prod-prerender.env Hard-coded http://localhost:3000 — for local dev only
Real environments (int1, qc1, qc2, prod) Not from the .env files — those are local-dev only. Real environments get it from infrastructure/cf-fetemplate.yaml (CloudFormation) !Ref WebAlbDnsNameSsm — the internal load-balancer address of the real prerender-service deployment, pulled from AWS SSM at deploy time

Same story for Redis in real environments — PRERENDER_REDIS_URL / PRERENDER_REDIS_PORT come from !Ref WebRedisEndpointSsm (a real AWS-managed Redis endpoint), not 127.0.0.1.

A second, separate switch

prerenderEnabled in the CloudFormation template sets PRERENDER_BOILERPLATE — this is a "which HTML marker convention to use" switch, not the master on/off switch. Don't confuse the two.

Running it locally

yarn start:prerender
SettingLocal valueMeaning
PRERENDER_SERVICE_URLhttp://localhost:3000Where the headless-browser render service is expected
PRERENDER_APP_ENDPOINThttp://localhost:8000The shop's own address — the prerender service calls this back
PRERENDER_MOCK_REDIStrueSkips real Redis, uses an in-memory fake — quickest way to test locally
PRERENDER_BOILERPLATEtrueUses the newer HTML placeholder convention

How To Check It's Working

You don't need server access — check response headers.

curl -sI https://<host>/en/sim-only | grep -i "x-prerender-cache"

Header present — X-Prerender-Cache: true

You were served a cached page. Prerender is active and this request was a hit.

Header absent

Either prerender is disabled entirely, or it's enabled but this request was a miss/skip — cookies are the usual reason (see next section).

Same check in Chrome DevTools → Network tab → click the document request → Response Headers → look for x-prerender-cache.

Also useful: view-source on the page. If you see var _isPrerender = false; and a populated var _hydratedData = {...} (not empty {}), that's a cached/prerendered page. Empty _hydratedData means it was rendered fresh in your browser.

Cookies That Override It

Two mechanisms stack together — one built into the library, one added by web-shop-simo.

1. Built into the library — applies to every shop

Cookie presentEffect
basketIdSkip — you're mid-purchase
JourneyIDSkip — you're in a journey
customerTransferTxnIdSkip — mid-transfer
Signed-in session cookie, assurance level ≥ 3Skip — logged in

2. web-shop-simo's own rule

skipChecker: (req) => req.cookies.basketId

Duplicates the basketId check above — belt and suspenders.

How to test this yourself

  1. Open the site in a private/incognito window (no cookies) → hit a plans page twice → the second request should show X-Prerender-Cache: true
  2. In DevTools → Application → Cookies, manually add a cookie basketId=test123
  3. Reload the same page → the header should now be absent — you fell through to a normal render, even though a cached copy exists for other visitors
  4. Delete the cookie, reload again → the cached header comes back

"I Changed The Price…" Scenario

Two different kinds of "stale", handled by two different mechanisms.

Case A — price/content changed, no deploy happenedCommon
  • The cached page has a freshness window — cacheSeconds: 300 (5 minutes) in web-shop-simo
  • For those 5 minutes, a visitor can see the old price in a cached page
  • Once 5 minutes pass, the next visitor still gets served the (now stale) page once more — but that request also silently triggers a background rebuild
  • The visitor after that gets the new price

Worst case: stale pricing for roughly cacheSeconds + one more visit's worth of time. This is a deliberate speed-vs-freshness trade-off — lower cacheSeconds if pricing needs to be fresher, at the cost of more traffic to the prerender service.

Case B — a new deployment happenedSelf-fixing
  • Every cached page is stamped with a buildHash (in web-shop-simo: the CI build ID)
  • After a deploy, the new server instance has a new buildHash
  • Old cached pages in Redis still carry the old buildHash
  • On the next request, the library sees the mismatch and refuses to serve the old page — a fresh render happens while it rebuilds

This is what stops old cached pages referencing deleted JS/CSS files after a deploy — no manual cache flush needed.

Summary

Content-only price changes → a few minutes of possible staleness, governed by cacheSeconds. Code deployments → self-invalidate immediately via buildHash.

Redis, Explained Simply

For anyone with zero Redis background.

Think of Redis as a shared whiteboard that every server instance can read and write to instantly.

What this library actually stores, per page

Key:   /plans/?segment=business          (the "label")
Value: {
  htmlContent: "<!DOCTYPE html>...",    the finished page
  statusCode: 200,
  timeOfCache: 1730000000000,             when it was made
  buildHash: "abc123",                     which app version made it
  isCurrentlyPrerendering: false           "is someone already rebuilding this?"
}

Only 3 Redis operations are ever used

OperationPlain meaning
exists(key)Is there anything stored under this label?
get(key)Give me what's stored under this label.
set(key, value, 'ex', seconds)Store this, and auto-delete it after N seconds (Redis's built-in expiry — no cleanup code needed).
If Redis goes down

The library catches the error, logs a warning, and falls through to a normal render. The site never breaks because Redis is unavailable — it just stops being fast.

Locally, PRERENDER_MOCK_REDIS=true swaps in MockRedis — the same 3 operations, backed by a plain JavaScript object in memory, so you can test without any real infrastructure.

Testing The New orgId Flow (PR #123)

PR #123 adds an optional organisationId setting so one shared cache can safely serve multiple organisations without their pages colliding. web-shop-simo doesn't use it yet, so testing it is two parts: prove the library behaves correctly on its own, then simulate it plugged into a real shop.

Part A — prove it in the library itself (fastest, no shop needed)

  1. Check out the PR branch in lib-web-prerender
  2. Run the existing unit tests:
    yarn test src/middleware/helpers/getCacheableUrl
    yarn test src/middleware/helpers/getCacheableUrls
  3. Confirm they pass, and note what they prove:
    • Setting organisationId: 'org-a' appends ?orgId=org-a to the cache key
    • Special characters get URL-encoded (org id/a&b=corg%20id%2Fa%26b%3Dc)
    • Request headers are ignored — only the server-side config value is used, which proves a visitor can't spoof it

Part B — simulate it plugged into web-shop-simo

  1. Link your PR branch's build into web-shop-simo locally (via .linkrc.js's tool, or yarn link / yalc)
  2. Temporarily add organisationId: 'test-org-a' into prerender.middleware.ts's config object
  3. Run yarn start:prerender with PRERENDER_MOCK_REDIS=true
  4. Hit a page in the browser, e.g. http://localhost:8000/plans
  5. Check the response header: X-Prerender-Cache: true should appear on a cache hit
  6. The real proof: change organisationId to 'test-org-b', restart, hit the same URL again — confirm it's a fresh cache miss, not reusing org-a's cached copy. Two separate keys should exist:
    /plans/?orgId=test-org-a
    /plans/?orgId=test-org-b
  7. Switch back to org-a — confirm it's still cached separately and unaffected by org-b's traffic
What "done" looks like
  • Two different organisationId values never share a cached page for the same URL
  • No organisationId set (default '') → behaves exactly as before, no orgId in the key
  • Nothing in the incoming request can change which organisation's cache is read or written — only server config can

The Big Picture

There are four moving parts. Only one of them is this repo. Understanding which is which removes most of the confusion people have when they first read the code.

PartWhat it isLives whereRole
The web shop An Express + JavaScript app e.g. web-shop-simo Owns the routes and the client-side renderer. Installs this library.
This library Express middleware lib-web-prerender Decides: serve from cache, or step aside. Manages the cache.
Redis Shared in-memory store Infrastructure Holds the rendered HTML plus metadata, keyed per page variant.
Prerender service Headless-browser HTTP service Separate deployment Takes a URL, loads it fully, returns finished HTML + captured API data.
The counter-intuitive bit

The prerender service renders the shop by requesting the shop's own URL. So the shop calls the prerender service, which calls the shop back. The library sets an x-prerender header path on that inner request so the middleware recognises it and skips itself — otherwise it would loop forever.

Request Lifecycle

This is the exact order of operations in src/middleware/prerender.js, the file that orchestrates everything else.

Once, at server start-up

Merge defaultOptions with your options
Normalise prerenderServerUrl
add https:// if missing
Substitute MockRedis
local/development only
Build the skip-checker

Then, on every request

1Should we skip entirely?Gate

shouldSkipPrerender(req, res) runs first. If it returns truthy the middleware immediately calls next() and does nothing else — no Redis access, no prerender request. See When Prerender Is Skipped for the full rule set.

2Resolve the build hash and URL hashKeying

If a getBuildHash(req) function was supplied it is awaited and overwrites options.buildHash for this request. Then getUrlHash(req, res) is awaited — consumers use this to add their own dimension (e.g. an experiment bucket or brand) into the cache key. It defaults to an empty string.

Note

options.buildHash is mutated on the shared options object, not on a per-request copy. It is only a problem if getBuildHash returns different values for concurrent requests.

3Compute both cache keysKeying

getCacheableUrls() returns { cacheableUrl, cacheableBotUrl } — the user key (includes the URL hash) and the bot key (prefixed static-, no URL hash). See Cache Keys Explained.

4Resolve a geolocation overrideOptional

If geoLocationConfig is an array, getGeoLocationOverride() finds the first entry whose url appears in req.url and returns it with a default accuracy of 10. This is forwarded to the prerender service so a location-specific page can be rendered (used for store/coverage style pages).

5Assemble prerenderOptionsPlumbing

One object carrying everything downstream helpers need: all resolved options, both cache keys, req.cookies, the optional geolocation, and — importantly — req, res and next themselves. Every helper takes this single object.

6Branch three waysDecision
  1. Googlebot and a bot cache existsreturnStaticCachedPage(). Sends the script-stripped HTML.
  2. A user cache entry existsreturnCachedPage(). Validates freshness and build, may refresh in the background, then sends or defers.
  3. Nothing cached → log, write the isCurrentlyPrerendering marker, fire forwardToPrerenderServer() without awaiting, and call next() so this visitor gets a normal client-side render.
The three-way branch
Request survives the skip gate
Googlebot + bot cache
static HTML, no scripts
User cache hit
serve, maybe refresh
Cache miss
CSR now, warm cache for later
The key insight

The visitor who causes a cache miss never waits for the prerender. They get a normal client-side render. The prerender happens in the background and benefits the next visitor. Cache warming is always a side effect, never a blocking step.

When Prerender Is Skipped

All of this lives in src/middleware/helpers/initShouldSkipPrerender/. This is the most safety-critical logic in the repo.

Hard skips — checked first, cheapest

ConditionWhy
No prerenderServerUrl Nothing to render with. The library disables itself rather than erroring.
Not a GET request
unless res.locals.isLambda
You cannot cache the HTML result of a POST. The Lambda escape hatch exists because in that environment the method is not reliably a plain GET.
URL has a file extension
via path.extname
.js, .css, .png etc. are static assets, not pages.
URL matches __vite_ping or @vite/client Local dev-server plumbing from Vite. Must never be prerendered.

Then: the forced-route override

If the request has no x-prerender header and its exact req.url is listed in alwaysPrerenderRoutes, the function returns false immediately — prerender this, regardless of cookies. This is how a shop guarantees its highest-value SEO landing pages are always cached.

Exact match only

The check is alwaysPrerenderRoutes.includes(req.url) — a full string comparison including the query string. /plans in the list will not match a request for /plans?segment=business.

Then: the personalisation skips

x-prerender header present

This request is the prerender service rendering the app. Skipping is what breaks the infinite loop. The consuming app also exposes this to the page as _isPrerender so it can, for example, suppress analytics during a server render.

The visitor has a journey

True if any of req.cookies.basketId, req.cookies.JourneyID, req.cookies.customerTransferTxnId is set, or the IDM session's assuranceLevel >= 3. Any of these means the page would be personal.

The consumer's own skipChecker

An optional async function given (req, res). Return truthy to skip. Used for per-shop rules, feature flags, or a manual bypass cookie during testing.

How the session is read

getSessionCookie looks up req.cookies['<prefix>_p_id_token'], falling back to req.cookies.Session, falling back to {}. The prefix is options.authCookiePrefix, then process.env.AUTH_COOKIE_PREFIX, then the literal 'eShop-auth'.

This requires cookie-parser

The middleware reads req.cookies directly. If cookie-parser is not mounted before it, req.cookies is undefined and the journey check throws. Mount cookieParser() first.

The three personalisation reasons are collected into an array and combined with .some(Boolean) — any one of them is enough to skip.

Cache Keys Explained

Files involved: getCacheableUrlsgetCacheableUrlgetParamString.

A cache key is the identity of a page variant. Get it too broad and two different audiences share one page. Get it too narrow and the cache never gets a hit. The library therefore builds the key from explicitly declared dimensions only.

The recipe

<static- if bot><environment><path with trailing slash><?query fragments>

Query fragments, in this fixed order:
  1. hash=<urlHash>                    (only if getUrlHash returned something)
  2. each key from queryParamsToRetain  that is present in the real query string
  3. each key from cookiesToRetain      that is present in req.cookies

Joined with &. A present-but-empty value contributes just the key name.

Worked examples

Incoming requestConfigResulting key
/plans no retained params, environment: '' /plans/
/plans?segment=business&utm_source=email queryParamsToRetain: ['segment'] /plans/?segment=business
/plans?segment=business as above + environment: 'dev' dev/plans/?segment=business
/plans with cookie brand=three cookiesToRetain: ['brand'] /plans/?brand=three
/plans, getUrlHash'abc' /plans/?hash=abc
Googlebot on /plans?segment=business as above static-/plans/?segment=business

Why the trailing slash is forced

/plans and /plans/ are the same page to a user. Normalising to a trailing slash collapses them into one cache entry instead of two.

Why utm_* is discarded

Marketing parameters change the URL but not the page. If they were part of the key, every campaign link would create its own cold cache entry. Only declared parameters count.

Why the bot key omits the URL hash

getCacheableUrls deliberately does not pass urlHash to the bot variant. Crawlers should see one canonical static page per URL, not one per experiment bucket.

getParamString does double duty

The same private builder produces cache-key fragments (&-joined, no terminator) and outbound Cookie header strings (space-joined, ;-terminated) via appendQueryToCache and getCookieHeaderString.

Adding to cookiesToRetain is a privacy decision

Anything in cookiesToRetain becomes part of a shared cache key and is also forwarded to the prerender service as a cookie. Never put a user identifier, token or session cookie in that list.

Cache States & Staleness

Logic in returnCachedPage, evaluated in exactly this order.

What is actually stored

{
  htmlContent: "<!DOCTYPE html>...",   // the finished page
  apiData: { ... },                     // captured window._hydratedData
  statusCode: 200,
  timeOfCache: 1730000000000,           // Date.now() when written
  isCurrentlyPrerendering: false,       // single-flight marker
  buildHash: "…"                        // release stamp
}

The state machine

#StateDetected byActionVisitor gets
1 Placeholder, in progress No statusCode, isCurrentlyPrerendering: true next() only Client-side render
2 Placeholder, failed No statusCode, isCurrentlyPrerendering: false Re-flag, warn, retry prerender in background Client-side render
3 Old build parsedData.buildHash !== buildHash Re-flag + background prerender (unless already flagged) Client-side render
4 Expired Date.now() - timeOfCache >= cacheSeconds * 1000 and not already flagged Re-flag + background prerender, then continue The stale cached page
5 Geo page, human visitor geoLocation set and user-agent is not a bot next() Client-side render
6 Shell server mode skipReturnCachedPage truthy next() Client-side render
7 Fresh hit None of the above Set X-Prerender-Cache: true, send The cached page
8 Unreadable Any thrown error, e.g. bad JSON Warn, flag, background prerender, next() Client-side render
States 3 and 4 differ in one important way

An old build is never served — the HTML references asset files that no longer exist, so it would be visibly broken. An expired page is served, because slightly stale prices for a few seconds are better than a slow page. That is the stale-while-revalidate trade-off.

Two different clocks

Logical freshness — cacheSeconds

Default 600 (10 minutes). Compared against timeOfCache in application code. Controls when a background refresh is triggered.

Physical lifetime — Redis TTL

Hard-coded: 86400 seconds (24h) for user pages, 259200 seconds (72h) for bot pages. After that, Redis evicts the key and the URL is a cold miss again.

How stampedes are prevented

Request A: miss
write isCurrentlyPrerendering: true
fire prerender
Requests B, C, D arrive
see the flag
next() — no extra prerender calls

When the prerender finishes, forwardToPrerenderServer overwrites the entry with isCurrentlyPrerendering: false. If it fails, handleInvalidPrerender clears the flag so a later request can retry.

Build Hash & Releases

A cached page contains <script src="/static/main.a1b2c3.js">-style references. Deploy a new release and those filenames change. Serving the old cached HTML would point browsers at files that 404 — a visibly broken page. The build hash is the guard against that.

1. The value

options.buildHash. Defaults to a fresh uuidv4() generated when defaultOptions is first imported. Consumers normally pass their webpack bundle hash instead, or supply getBuildHash(req) for a per-request lookup.

2. It must appear in the rendered HTML

getIsCacheable calls isCurrentBuild(html, buildHash), which is a plain html.includes(buildIdentifier). If the string is absent the render is rejected and never cached — with the log line "BuildHash not found in server render".

3. It is stored alongside the HTML

Written into the cache object as buildHash.

4. It is compared on every read

A mismatch in returnCachedPage means "cached by a previous release": regenerate in the background and client-side render this request. The whole cache therefore self-invalidates after a deploy, without anyone flushing Redis.

The default is only viable for a single instance

The default uuidv4() is generated per Node process. With multiple instances behind a load balancer, each has a different hash, so instances constantly reject each other's cache entries and nothing ever stays cached. Production consumers must pass a real, deployment-stable buildHash.

Googlebot / Static Flow

Detection

isGooglebotRequest(headers) is deliberately blunt: it checks that a user-agent header exists and contains the substring Googlebot. Separately, returnCachedPage uses the isbot package for the broader "is this any crawler" question in the geolocation branch.

Why bots get their own copy

Scripts removed

removeScriptTags strips every <script>…</script> block except those with type="application/ld+json", so JSON-LD structured data survives while nothing executes.

Longer life

72 hours in Redis versus 24, because crawl budget is unpredictable and a crawler hitting a cold cache is a wasted crawl.

No hydration data

The bot entry stores only htmlContent, statusCode and timeOfCache — no apiData, no buildHash. A crawler does not boot the app, so asset filenames do not matter.

Not freshness-checked

returnStaticCachedPage reads the entry and sends it. There is no expiry comparison and no background refresh: the bot cache is refreshed only as a side effect of a user-driven prerender for the same URL.

Where the two caches are written

Both are written in the same place — forwardToPrerenderServer — from the same render. The bot entry goes first, then the user entry.

One prerender response
placeholders replaced
bot key
scripts stripped · 72h
user key
full HTML + apiData · 24h
Consequence worth knowing

If a URL is only ever visited by Googlebot and never by a user, its bot cache is never populated — the bot branch only reads. Googlebot falls through to returnCachedPage or a cache miss, which is what eventually warms it.

Install & Wire Up

1. Install

yarn add @vfuk/lib-web-prerender

# axios is a peerDependency — the consumer must provide it
yarn add axios

2. Mount it, in the right order

import cookieParser from 'cookie-parser'
import prerender from '@vfuk/lib-web-prerender'
import redis from './redis'
import clientSideRender from './clientSideRender'

app.use(cookieParser())            // REQUIRED: middleware reads req.cookies

app.use(prerender({
  redis,
  queryParamsToRetain: ['commitmentPeriod', 'segment', 'referrer'],
  htmlReplacements: [],
  cacheSeconds: 300,
  prerenderServerUrl: process.env.PRERENDER_SERVICE_URL || 'http://localhost:3000',
  utagSyncScript: process.env.TEALIUM,
  buildHash: process.env.BUILD_HASH,
}))

app.get('/*', clientSideRender)     // MUST come after prerender
Order is not negotiable

prerender() either responds or calls next(). If it is mounted after your renderer, the renderer will already have responded and the middleware does nothing. If cookie-parser is mounted after it, the skip checks throw.

3. Provide a Redis client

import Redis from 'ioredis'

const redisNode = { port: process.env.REDIS_PORT, host: process.env.REDIS_URL }
const redis = new Redis.Cluster([redisNode])

redis.on('connect', () => console.info('Connected to Redis.'))
redis.on('error', (err, data) => console.info('An error occurred with Redis. ', err, data))

export default redis

Only four methods are used: exists, get, set(key, value, 'ex', seconds). Anything implementing those will work.

4. Add the HTML markers

See The HTML Contract — without these the cache will refuse to store anything.

All Options Reference

Defaults come from src/middleware/constants/defaultOptions.js; your object is spread over them.

Required

OptionTypeWhat it does
prerenderServerUrl string Base URL of the prerender service. The library POSTs to <url>/render. If it does not start with http, https:// is prepended. If falsy, the middleware disables itself entirely.
redis object The cache store. In local/development a falsy value is replaced with an in-memory MockRedis.

Caching & keying

OptionDefaultWhat it does
cacheSeconds 600 Logical freshness window. Past it, a background refresh is triggered but the stale page is still served.
queryParamsToRetain [] Query parameters that create distinct cached pages. Also the only parameters forwarded to the prerender service in the URL being rendered.
cookiesToRetain [] Cookies that create distinct cached pages. Also sent as a Cookie header on the prerender request. Never list identity cookies here.
environment undefined Prefix on every cache key. Lets several environments safely share one Redis cluster. Not in defaultOptions, so unset means no prefix.
getUrlHash () => Promise.resolve('') Async (req, res) hook returning an extra key dimension, appended as hash=…. Applied to the user key only, never the bot key.
buildHash uuidv4() Release stamp. Must be present in the rendered HTML, and must be stable across instances.
getBuildHash unset Async (req) hook; if provided, its result overrides buildHash per request.

Skipping & auth

OptionDefaultWhat it does
skipChecker unset Async (req, res). Truthy return means skip prerendering for this request.
authCookiePrefix 'eShop-auth' Prefix for the IDM token cookie <prefix>_p_id_token. Falls back to process.env.AUTH_COOKIE_PREFIX then the literal default.
alwaysPrerenderRoutes unset Array of exact req.url strings that bypass the cookie/journey skips.
skipReturnCachedPage unset When truthy, cached pages are validated and refreshed but never sent — every request falls through to next(). Used by shell-server style deployments.

HTML output

OptionDefaultWhat it does
htmlReplacements [] Extra { placeholder, replacement, boilerplatePlaceholder? } entries applied after the built-in replacements.
utagSyncScript '' Tealium utag.sync URL. When set, <script>var headUtagToReplace = true;</script> becomes a real <script src>.
cdnDomain '' Outside NODE_ENV=local, every occurrence of prerenderServerUrl in the HTML is replaced with this (or with an empty string if unset), so assets are not requested from the prerender host.

Request behaviour

OptionDefaultWhat it does
prerenderRequestTimeout 30000 Axios timeout in ms for the render call.
disableOutstandingRequiredRequests false When true, skips the "were all required API calls complete?" gate. Use only if your app does not use the required flag on hydrate().
geoLocationConfig unset Array of { url, latitude, longitude, accuracy? }. The first entry whose url is a substring of req.url is forwarded to the prerender service. accuracy defaults to 10.

The HTML Contract

The library rewrites the HTML the prerender service returns by doing literal string replacements. Your app's HTML must therefore contain the exact marker strings. These are not configurable defaults — they are hard-coded in defaultReplacements.

Marker your HTML must containReplaced withCondition
var _isPrerender = true; var _isPrerender = false; Always
var _hydratedData = {}; var _hydratedData = <captured JSON>; Always
var vfukTarget = {}; var vfukTarget = {data: … }; Only if the render captured vfukTargetData
<script>var headUtagToReplace = true;</script> <script src="<utagSyncScript>"></script> Only if utagSyncScript is set
Any occurrence of prerenderServerUrl cdnDomain (or '') When NODE_ENV !== 'local'

Minimal template

<head>
  <script>var headUtagToReplace = true;</script>
  <script>
    var _hydratedData = {};
    var _isPrerender = ${this.isPrerender};
  </script>
  <!-- buildHash must appear somewhere in the document -->
</head>

_isPrerender

Set by the app to !!req.headers['x-prerender']. It is true only while the prerender service is rendering, and is forced to false in the cached copy. Use it to suppress analytics and other browser-only side effects during a server render.

_hydratedData

During the render, hydrate() writes API responses onto window._hydratedData. The prerender service is instructed to copy it to window.prerenderData, and the library inlines that JSON into the cached HTML so the client starts with the data already present.

Boilerplate migration

When PRERENDER_BOILERPLATE=true, replacePlaceholders prefers each entry's boilerplatePlaceholder if present. The only built-in that defines one is the hydration marker, whose boilerplate form is the comment /* PRERENDER_INJECT */.

Replacements are single-occurrence

String.prototype.replace with a string pattern replaces only the first match. Only the prerenderServerUrl entry uses a global RegExp. Markers must appear exactly once.

Using hydrate()

hydrate is the client-side half of the library. It wraps an API call so that the same call is made once during the prerender and then not repeated in the browser.

import hydrate from '@vfuk/lib-web-prerender/lib/helpers/hydrate'

@action getCachedJourney = async () => {
  const journey = await hydrate('journey', () => {
    return this.simoService[this.journeyToStart](this.journeyProps) || {}
  })
  this.loadJourney(journey)
}

Signature

hydrate(nameOfData: string, dataCall: () => Promise<any>, isRequired = false)

nameOfData

The key under window._hydratedData. Must be unique and stable — it is the identity of the cached payload.

dataCall

Executed only when there is no cached value for that key.

isRequired

When true, increments outstandingRequiredRequests before the call and decrements after. If it is not back to 0 when the snapshot is taken, the whole render is rejected as incomplete.

Behaviour

hydrate('journey', fn)
Is window._hydratedData.journey set?
YES → resolve the cached value, no network call
NO → run fn, store the result under the key, resolve it
isCachedData

When a cached object is returned it is spread with { isCachedData: true, ...cachedData } so consuming code can tell it came from the prerender. Cached arrays are returned unchanged (adding a property to an array would break it).

The related helpers

requiredForPrerender(dataCall)

The counter mechanics of hydrate without the caching. Use it to mark work as required for a valid render when there is no payload to store.

prerenderStaticPage()

Call it on a page that makes no API calls. It initialises _hydratedData.outstandingRequiredRequests = 0 during a prerender so the cacheability gate passes instead of seeing undefined.

lazyHydrate({ module, fallback, errorComponent })

A React helper for code-split components. Before the dynamic import resolves, it re-renders the static HTML the prerender already produced for that component ID, so there is no flash of empty space.

useLoadDynamicImport(module)

The hook behind lazyHydrate. Calls module.getComponent() and returns { dynamicComponent, isLoading, error }.

Import paths

The package's default export is the middleware only (src/index.js re-exports middleware/prerender). The client helpers are not re-exported from the root, so they are imported by their built path under lib/helpers/….

Running It Locally

Working on the library itself

CommandWhat it does
yarn testJest with NODE_ENV=test. Unit + integration tests.
yarn buildModern build to lib/ then legacy build to lib/legacy/, then copies package.json, .npmignore, .npmrc.
yarn build:devWebpack dev build via config/webpack/dev.config.js.
yarn devNodemon watch loop around yarn start.
yarn jsdocs:buildGenerates API HTML docs with documentation.
Some scripts reference paths that no longer exist

build:dev, start, dev, jsdocs:build, build:docs and docs:serve point at config/webpack/, src/package, lib/main.js and docs/website — none of which are present in the repo today. In practice the working scripts are test and build. sonar-project.properties is likewise still the untouched template (lib-web-your-package-name, sonar.sources=./src/package).

Testing your change inside a shop

.linkrc.js configures the internal linking tool: it builds the library with yarn build, links lib/ into a consumer whose name starts with web-shop- (flattening the lib directory name), copies package.json across, cleans both ends, and watches ./src for js/json/ts/jsx changes.

Running without Redis

With NODE_ENV set to local or development and no redis option, MockRedis is injected — a plain in-memory object with exists, get and set. Good enough to exercise caching without the Redis Docker image. It ignores TTLs and dies with the process.

Local URL rewriting

buildPrerenderUrl hard-codes the app endpoint to http://localhost:8000 when NODE_ENV is local, development or test — so the prerender service is told to render your local app rather than a deployed one. Set PRERENDER_BOILERPLATE=true and PRERENDER_APP_ENDPOINT to override that.

Ordered File Map

Read the files in this order and the codebase will make sense on the first pass. The ordering follows the path a single HTTP request takes, not the alphabetical layout on disk.

A repeated pattern you will see everywhere

Almost every helper is a folder containing three files: index.js (a one-line re-export), thing.js (the implementation, one exported function), and thing.test.js. So import x from '../getCacheableUrl' resolves through the folder's index.js. The index.js files carry no logic and can be ignored while reading.

#FileOne-line jobImportance
1src/index.jsPackage entry point; re-exports the middleware.Trivial
2src/middleware/prerender.jsThe orchestrator. Reading only this file tells you 80% of the story.Critical
3constants/defaultOptions.jsEvery default value in one place.High
4helpers/fixPrerenderServerUrl/Prepends https:// when missing.Low
5helpers/initShouldSkipPrerender/The privacy and safety gate.Critical
6helpers/getSessionCookie/Finds the IDM session cookie.Medium
7helpers/getCacheableUrls/Produces both cache keys.High
8helpers/getCacheableUrl/Builds one cache key.High
9helpers/getParamString/Shared key-fragment / cookie-header builder.Medium
10helpers/getGeoLocationOverride/Matches a URL to a lat/long config.Low
11helpers/isGooglebotRequest/User-agent substring check.Low
12helpers/returnStaticCachedPage/Serves the bot cache.Medium
13helpers/returnCachedPage/The cache-state machine.Critical
14helpers/forwardToPrerenderServer/Calls the render service and writes both caches.Critical
15helpers/buildPrerenderUrl/Works out what URL to render and where to POST.High
16helpers/getPrerenderableUrl/Path + filtered query for the render.Medium
17helpers/getFilteredQueryString/Keeps only declared query params.Medium
18helpers/getRequestOptions/Builds the axios request.High
19.../getIsCacheable/Three-part correctness gate on the render.Critical
20helpers/isCurrentBuild/html.includes(buildHash).Low
21helpers/replacePlaceHolders/Applies the replacement list.High
22helpers/defaultReplacements/Defines the built-in HTML contract.High
23helpers/removeScriptTags/Strips scripts for the bot copy.Medium
24helpers/cachePageResponse/The only Redis write path.Medium
25helpers/setIsCurrentlyPrerenderingInCache/Sets the single-flight marker.High
26helpers/handleInvalidPrerender/Clears the marker after a failure.Medium
27utils/MockRedis/In-memory Redis stand-in for local dev.Low
28src/helpers/hydrate/Client-side data capture & reuse.Critical
29src/helpers/requiredForPrerender/Marks work as required for a valid render.Medium
30src/helpers/prerenderStaticPage/Makes API-free pages cacheable.Medium
31src/helpers/lazyHydrate/Reuses prerendered HTML for lazy React components.Medium

1. Entry Points

src/index.js1 line
export { default } from './middleware/prerender'

Simply: the front door. It says "the thing this package gives you is the prerender middleware".

Technically: the package's main is index.js relative to the published lib/, so this compiles to lib/index.js. Note that only the middleware is re-exported — the client-side helpers are reached by deep path.

src/middleware/index.js1 line
export { default } from './prerender'

Simply: the same thing one level down, so import … from './middleware' works.

2. The Core Middleware

src/middleware/prerender.jsMost important file

Simply: the manager. It does no detailed work itself — it asks the right specialist at the right moment and decides whether to answer the visitor or hand the job back to the website.

Technically: a factory prerender(customOptions) that closes over resolved options and returns an async Express middleware.

Setup phase (runs once)

  1. { ...defaultOptions, ...customOptions, ...fixPrerenderServerUrl(...) } — note the URL fix is spread last, so it always wins.
  2. In local/development, options.redis ||= new MockRedis(). Marked TODO: deprecated pending the boilerplate migration.
  3. initShouldSkipPrerender(options) is called once and its returned closure reused.

Per-request phase

  1. await shouldSkipPrerender(req, res)next() and return.
  2. getBuildHash / getUrlHash resolution.
  3. getCacheableUrls → both keys.
  4. getGeoLocationOverride, only when geoLocationConfig is an array.
  5. Build prerenderOptions, carrying req/res/next.
  6. The three-way branch on Googlebot / cache hit / miss.

Why it is written this way

  • One options object passed everywhere keeps helper signatures uniform and makes them trivially testable.
  • The miss branch does not await forwardToPrerenderServer, which is what keeps latency flat on a cold cache.
  • Two exists calls, not get — the branch only needs to know whether a key is present; the body is fetched inside the chosen handler.
src/middleware/constants/defaultOptions.jsHigh

Simply: the sensible-defaults sheet. Anything a consumer does not specify comes from here.

Technically: a frozen-by-convention object literal. The one live expression is buildHash: uuidv4(), evaluated at import time — i.e. once per Node process. See the warning in Build Hash & Releases.

Note what is absent: environment, skipChecker, getBuildHash, alwaysPrerenderRoutes, geoLocationConfig, skipReturnCachedPage, authCookiePrefix and redis have no defaults and are simply undefined unless supplied.

3. Setup & Skip Helpers

helpers/initShouldSkipPrerender/initShouldSkipPrerender.jsCritical

Simply: the bouncer. It answers one question — "is it safe and worthwhile to serve this visitor a shared page?" If not, the library stands aside.

Technically: a curried function; the outer call binds options at start-up, the inner async call evaluates per request. Returns true to skip.

Evaluation order: hard skips → alwaysPrerenderRoutes override → [hasPrerenderHeader, hasJourney, skipChecker?] combined with .some(Boolean). Full rule table in When Prerender Is Skipped.

Why it matters: this file is the privacy boundary. A bug here means a logged-in customer's page could be cached and served to a stranger. Treat every change as security-relevant.

helpers/getSessionCookie/getSessionCookie.jsMedium

Simply: finds the cookie that says whether someone is signed in.

const cookiePrefix = authCookiePrefix || process.env.AUTH_COOKIE_PREFIX || 'eShop-auth'
return req.cookies[`${cookiePrefix}_p_id_token`] || req.cookies.Session || {}

Returning {} on failure is deliberate: the caller reads session.assuranceLevel, and undefined >= 3 is false, so an absent session correctly means "not logged in" rather than throwing. It has no test file — the behaviour is covered through initShouldSkipPrerender's tests.

helpers/fixPrerenderServerUrl/fixPrerenderServiceUrl.jsLow

Simply: if someone configured prerender.internal instead of https://prerender.internal, this adds the missing bit.

Returns { prerenderServerUrl } rather than a bare string so it can be spread over the options object in one expression. Note the filename (…ServiceUrl.js) differs from its folder and its test file (…ServerUrl.test.js) — harmless, but confusing when grepping.

utils/MockRedis/MockRedis.jsLow

Simply: a pretend Redis, so you can develop without running the real one.

export default class MockRedis {
  store = {}
  exists = async (key) => !!this.store[key]
  set = async (key, value) => (this.store[key] = value)
  get = async (key) => this.store[key]
}

It ignores the 'ex' TTL arguments entirely and is per-process, so nothing expires and nothing is shared. Only injected when NODE_ENV is local or development.

4. Cache Key Helpers

Concepts and worked examples are in Cache Keys Explained; this is the file-level view.

helpers/getCacheableUrls/getCacheableUrls.jsHigh

Simply: makes the two labels — one for people, one for Google.

Technically: calls getCacheableUrl twice. The bot call passes isBot: true and deliberately omits urlHash, so crawlers get one canonical entry per URL rather than one per experiment variant.

helpers/getCacheableUrl/getCacheableUrl.jsHigh

Simply: writes the label for one page.

Technically: splits req.url on ?, parses the query with qs, forces a trailing slash on the path, then appends — in this order — the URL hash, the retained query params, and the retained cookies. Result:

`${isBot ? 'static-' : ''}${environment}${urlWithoutQuery}${queryString}`
Ordering is positional, not sorted

Fragments follow the order of the queryParamsToRetain / cookiesToRetain arrays, not the order in the incoming URL. That is what makes keys stable — but it also means reordering those arrays invalidates the entire cache.

qs is used but is not declared in package.json — it resolves transitively (via Express). Worth knowing if dependencies are ever pruned.

helpers/getParamString/getParamString.jsMedium

Simply: one small string-builder used for two different jobs.

Technically: a private _getParamString with an isForCacheKey switch that selects the separator and terminator, exposed as two named functions:

ExportSeparatorTerminatorUsed byExample
appendQueryToCache&nonegetCacheableUrlsegment=business&brand=three
getCookieHeaderStringspace;getRequestOptionssegment=business; brand=three;

A key that is present but empty contributes just its name — the presence of a parameter can be meaningful even without a value.

helpers/getFilteredQueryString/getFilteredQueryString.jsMedium

Simply: throws away the query parameters nobody declared as important, and returns what is left with a ? in front.

Very close to appendQueryToCache but separate because it starts a fresh string and prefixes ?. Returns '' when there is no query string or nothing to retain. Used for the URL sent to the prerender service, not for the cache key.

helpers/getGeoLocationOverride/getGeoLocationOverride.jsLow

Simply: "if this page is about a place, tell the renderer to pretend it is standing there."

Finds the first geoLocationConfig entry whose url is a substring of req.url, returns it with accuracy defaulted to 10, and returns undefined when nothing matches. The result is forwarded in the prerender request body.

helpers/isGooglebotRequest/isGooglebotRequest.jsLow

Simply: "does the visitor say it is Google?"

return !!headers['user-agent'] && headers['user-agent'].includes('Googlebot')

A substring check on a spoofable header. That is acceptable because the worst outcome is that someone receives a script-free page. Other crawlers do not get the static branch — for the broader question, returnCachedPage uses the isbot package instead.

5. Serving Helpers

helpers/returnCachedPage/returnCachedPage.jsCritical

Simply: the quality inspector on the hot counter. It picks up the ready-made page and runs through a checklist before handing it over — is it finished? is it from the current menu? is it too old? Depending on the answers it serves it, orders a replacement, or waves the visitor through to the kitchen.

Technically: reads and JSON-parses the entry, then evaluates eight states in a fixed order. Full table in Cache States & Staleness.

The three things it can do

  • next() — let the app render client-side.
  • res.status(...).send(...) with X-Prerender-Cache: true.
  • Fire forwardToPrerenderServer(options) without awaiting — a background refresh.

Details that are easy to miss

  • The absence of statusCode is how a placeholder is recognised. The single-flight marker written by setIsCurrentlyPrerenderingInCache has only isCurrentlyPrerendering and timeOfCache, so "no status code" means "no real page here yet".
  • The expiry branch does not return. It triggers the refresh and then falls through to send the stale page — that is the stale-while-revalidate behaviour.
  • The geolocation branch inverts the usual logic: if the page has a geolocation override and the visitor is not a bot (checked with isbot), the cached page is not served. A location-specific cached page is for crawlers; real users get a live render.
  • The status code is replayed. A cached 404 is sent as a 404, not a 200.
  • The file begins with stray ;;. Cosmetic, harmless, but it is really there.
helpers/returnStaticCachedPage/returnStaticCachedPage.jsMedium

Simply: hands Google the script-free copy. No checks, no timers — read it and send it.

Technically: redis.get(cacheableBotUrl) → parse → set X-Prerender-Cache: trueres.status(...).send(...). Any error is warned and next() is called.

Deliberately much simpler than returnCachedPage: no freshness check, no build-hash check, no background refresh. A crawler does not execute the page, so a slightly old static copy is harmless, and its Redis TTL of 72h is the only lifetime control.

6. Prerender Request Helpers

helpers/forwardToPrerenderServer/forwardToPrerenderServer.jsCritical

Simply: the cook. It asks the rendering service for a fully-built page, checks the result is good, tidies up the HTML, and puts two copies on the counter — one for people, one for Google.

Step by step

  1. buildPrerenderUrl{ requestUrl, urlToRender }.
  2. getRequestOptions → the axios config.
  3. await axios(options).
  4. getIsCacheable(response, buildHash, disable…). If false → handleInvalidPrerender and stop; nothing is cached.
  5. replacePlaceholders(response.data.content, [...defaultReplacements(...), ...htmlReplacements]) — consumer replacements run last, so they can override.
  6. Write the bot entry: removeScriptTags(completeHtml), status, timestamp, TTL 259200.
  7. Write the user entry: full HTML, apiData, status, timestamp, isCurrentlyPrerendering: false, buildHash, TTL 86400.
It is always fired without await

Every call site — the miss branch in prerender.js and three branches in returnCachedPage — invokes it as fire-and-forget. That is intentional (nobody waits for a render) but it means its rejections are swallowed by its own internal try/catch, and failures are visible only through [Prerender] log lines.

It writes to Redis directly with redis.set(...) rather than going through cachePageResponse, which is why the TTLs appear here as literals.

helpers/buildPrerenderUrl/buildPrerenderUrl.jsHigh

Simply: works out two addresses — where to send the render request, and which page the renderer should load.

requestUrl:   `${prerenderServerUrl}/render`
urlToRender:  `${appServerEndpoint}${urlToPrerender}`

appServerEndpoint is decided by environment:

ConditionApp endpoint used
NODE_ENV is local, development or testhttp://localhost:8000 (hard-coded)
PRERENDER_BOILERPLATE === 'true'PRERENDER_APP_ENDPOINT ?? prerenderServerUrl
OtherwiseprerenderServerUrl

The boilerplate check runs after the local check, so it wins when both apply. The default case — telling the prerender service to render prerenderServerUrl itself — is why defaultReplacements later rewrites that host to cdnDomain in the cached HTML.

helpers/getPrerenderableUrl/getPrerenderableUrl.jsMedium

Simply: the page path plus only the query parameters that matter.

Splits req.url and re-joins it with getFilteredQueryString(...). This keeps the rendered page aligned with the cache key: if a parameter is not worth its own cache entry, it is not sent to the renderer either.

helpers/getRequestOptions/getRequestOptions.jsHigh

Simply: fills in the form that is sent to the rendering service.

{
  method: 'post',
  url: requestUrl,                 // <prerenderServerUrl>/render
  followRedirect: false,
  withCredentials: true,
  headers: { Cookie?: '…' },       // only if cookiesToRetain is non-empty
  timeout: prerenderRequestTimeout,
  data: {
    renderType: 'html',
    javascript: 'window.prerenderData=window._hydratedData',
    url: urlToRender,
    geoLocation?: { … },
  },
  httpsAgent: new HttpsAgent({ rejectUnauthorized: false }),
  validateStatus: () => true,
}

The three lines worth understanding

  • javascript: a snippet the headless browser runs after the page settles. It copies the app's accumulated _hydratedData onto prerenderData, which is how the captured API responses come back in the response body.
  • validateStatus: () => true: axios never throws on an HTTP status. getIsCacheable owns that judgement instead — that is how a legitimate 404 can be cached.
  • rejectUnauthorized: false: accepts self-signed certificates on the internal prerender host. A new HttpsAgent is constructed per request, so the keep-alive pooling agentkeepalive provides is not actually shared between requests.

followRedirect is not an axios option (axios uses maxRedirects), so it has no effect. validateStatus is also spelled without the axios validateStatus casing issue — it is correct — but the property axios reads is validateStatus only in v1; confirm against the pinned axios version before relying on it.

…/forwardToPrerenderServer/helpers/getIsCacheable/getIsCacheable.jsCritical

Simply: the taste test. Three questions, and a "no" to any of them means the page is thrown away rather than stored.

CheckPasses whenWhy it exists
safeToCache Status 200–299, or exactly 404 A 500 or 302 is not a page. A 404 is a real, cacheable page.
requiredCallsSuccessful prerenderData.outstandingRequiredRequests === 0, or status 404, or the option is disabled A non-zero counter means a required API call had not returned when the snapshot was taken — the page is incomplete.
isCurrentBuild HTML contains the current buildHash Guards against caching a render produced by a different release.

Each failure logs a distinct [Prerender] warning, which makes this the single most useful file to grep for when pages mysteriously never cache.

helpers/isCurrentBuild/isCurrentBuild.jsLow
const isCurrentBuild = (html, buildIdentifier) => html.includes(buildIdentifier)

Simply: "does this page mention the current version number?" One line, no test file, but load-bearing — it is the reason a deploy invalidates the cache.

Empty-string trap

'anything'.includes('') is true. An empty or undefined-coerced buildHash makes this check pass unconditionally, silently disabling release invalidation.

7. HTML Transform Helpers

helpers/defaultReplacements/defaultReplacements.jsHigh

Simply: the standing list of find-and-replace edits every cached page gets.

Technically: builds an array of { placeholder, replacement } — two always, three more conditionally. Full table in The HTML Contract.

Why each edit exists

  • _isPrerender → false: the cached HTML was produced during a prerender but will be served to real browsers, which must run their analytics and browser-only code.
  • _hydratedData → JSON: inlines the captured API responses so the client boots without repeating them. This is the pay-off of the whole hydrate mechanism.
  • vfukTarget: same idea for Adobe Target personalisation data, added only when the render captured vfukTargetData.
  • prerenderServerUrl → cdnDomain: the only global RegExp in the list. Without it, cached pages would ask browsers to fetch assets from the internal prerender host. Skipped when NODE_ENV === 'local'.
  • headUtagToReplace: swaps a placeholder script tag for the real Tealium utag.sync.js, which must load synchronously in <head>.
helpers/replacePlaceHolders/replacePlaceholders.jsHigh

Simply: actually performs the edits, one after another.

replacementList.forEach((item) => {
  let placeholderKey = item.placeholder
  if (process.env.PRERENDER_BOILERPLATE === 'true') {
    placeholderKey = item.boilerplatePlaceholder || item.placeholder
  }
  amendedHtml = amendedHtml.replace(placeholderKey, item.replacement)
})

The PRERENDER_BOILERPLATE branch is marked TODO: deprecated — it exists so the same library version works against both the old markup and the new boilerplate markup during migration.

First match only

String patterns replace a single occurrence. Duplicate markers in your template will leave the second one un-replaced — typically showing up as an empty _hydratedData in the browser.

Folder is replacePlaceHolders (capital H) but the file is replacePlaceholders.js (lower h). Case-sensitive filesystems care; macOS does not.

helpers/removeScriptTags/removeScriptTags.jsMedium

Simply: takes the interactive bits out of the copy meant for Google, but keeps the machine-readable product information.

html.match(/<script(?:.*?)>(?:[\S\s]*?)<\/script>/gi)
  // remove each match unless it contains 'application/ld+json'

Regex-based rather than DOM-based because it must be fast and there is no parser available server-side here. Wrapped in try/catch returning the original HTML on failure — a bot page with scripts is better than no bot page.

Keeping JSON-LD is the entire point: that is where structured product, price and breadcrumb data lives, and it is what rich search results are built from.

8. Cache Write Helpers

helpers/cachePageResponse/cachePageResponse.jsMedium

Simply: writes an object into Redis as text, and refuses to crash if Redis is unhappy.

await redis.set(cacheableUrl, JSON.stringify(cachedObject), 'ex', 86400)

Swallows errors with a warning — a failed cache write must never break a response. Note the 24h TTL is hard-coded here too, so a placeholder marker also lives for 24h if never overwritten.

helpers/setIsCurrentlyPrerenderingInCache/…jsHigh

Simply: puts up a "being cooked" sign so several visitors do not all order the same dish at once.

Technically: two modes:

  • With an existing cacheObject — sets the flag on it and rewrites it, so the currently-cached HTML survives while the refresh runs. On failure it falls back to writing a bare marker.
  • Without one — writes a bare marker { isCurrentlyPrerendering, timeOfCache }. This is the object whose missing statusCode tells returnCachedPage "no real page here yet".

The distinction matters: a cold miss must not serve anything, but an expiring page must keep serving while it refreshes.

helpers/handleInvalidPrerender/handleInvalidPrerender.jsMedium

Simply: takes the "being cooked" sign down when the cooking failed, so someone can try again.

Reads the entry, sets isCurrentlyPrerendering = false, writes it back, then sets its local redis variable to null (a no-op for the caller — reassigning a parameter does not affect the caller's reference).

It has no try/catch and no test file

If the entry is missing or unparseable, JSON.parse(null) returns null and assigning a property to it throws. Because every call site invokes it inside a try or without awaiting, the throw is contained — but the flag then stays set until the Redis TTL expires, which is the classic cause of "this URL stopped caching and won't recover".

9. Client-Side Helpers

These ship in the same package but run in the browser (and inside the headless browser during a render), not in Express. They are the other half of the contract.

src/helpers/hydrate/hydrate.jsCritical

Simply: "ask for this data, but if the prerender already fetched it, just use that."

Technically: returns a Promise. If window._hydratedData[nameOfData] exists, resolves it immediately — arrays as-is, objects spread with { isCachedData: true, ... }. Otherwise it initialises _hydratedData and the outstandingRequiredRequests counter, optionally increments that counter, awaits dataCall(), stores the result under the key, decrements, and resolves.

Why this exists: without it, a prerendered page would arrive fully drawn and then immediately re-request every API on boot — the visitor would see a flash of loading states and the cache would have saved nothing but the first paint. Full usage guidance in Using hydrate().

Uses the new Promise(async (resolve, reject) => …) anti-pattern. It works because the body is wrapped in try/catch, but a throw outside that catch would be lost.

src/helpers/requiredForPrerender/requiredForPrerender.jsMedium

Simply: "the page is not finished until this has come back."

The counter half of hydrate with no caching: increment, await, decrement. Use it for work the render must wait for but whose result should not be stored — and remember that if it rejects, the counter is never decremented, so the render is permanently judged incomplete and the page will never cache.

src/helpers/prerenderStaticPage/prerenderStaticPage.jsMedium

Simply: for pages with no data to fetch, this says "nothing to wait for — go ahead and cache me."

if (window._isPrerender) {
  window._hydratedData = window._hydratedData || {}
  window._hydratedData.outstandingRequiredRequests =
    window._hydratedData.outstandingRequiredRequests || 0
}

Without it, a purely static page never touches hydrate, so outstandingRequiredRequests is undefined, undefined === 0 is false, and getIsCacheable rejects the render. A tiny file that fixes a confusing "my static page won't cache" failure.

src/helpers/lazyHydrate/lazyHydrate.jsMedium

Simply: for parts of the page that load their code separately, this shows the version the prerender already drew instead of an empty box while the code downloads.

Technically: a higher-order component factory. Given { module: { scope, name, getComponent }, fallback, errorComponent } it:

  1. Computes compId = `${scope}-${name}` and grabs document.getElementById(compId) at factory time — the prerendered markup.
  2. Returns a component that calls useLoadDynamicImport(module).
  3. While loading: if prerendered HTML was found, renders it back via dangerouslySetInnerHTML; otherwise renders the supplied fallback.
  4. On error: renders errorComponent or a plain <div>Error</div>.
  5. Once resolved: renders the real component inside <div id={compId}>, which is what makes the ID available for the next prerender.

The wrapper div id is the whole mechanism — it is the anchor that links a prerendered fragment to the component that will eventually replace it.

…/lazyHydrate/helpers/useDynamicImport/useLoadDynamicImport.jsLow

Simply: downloads the component's code and reports whether it is ready.

A hook that calls module.getComponent() in an effect keyed on [scope, name] and returns { dynamicComponent, isLoading, error }. Stores the component with setDynamicComponent(() => module.default) — the functional form, because React would otherwise treat a function value as a state updater.

File Importance Tiers

Not all 31 modules deserve equal attention. If you have twenty minutes, read Tier 1. If you have an hour, read Tiers 1 and 2. Tier 4 you can safely skim forever.

Tier 1 — Read these first

Critical

Change these and you change the behaviour of every Vodafone shop.

🎯

middleware/prerender.js

The orchestrator. Every request passes through it and it names every other participant.

🚧

initShouldSkipPrerender

The privacy boundary. Decides who may be served shared HTML. Security-relevant.

🔄

returnCachedPage

The eight-state cache machine. Nearly all cache behaviour questions are answered here.

🍳

forwardToPrerenderServer

The only place a render is requested and the only place both caches are written.

getIsCacheable

The three-part gate. If pages never cache, the reason is logged here.

💧

helpers/hydrate

The client-side contract. Without it, prerendering saves the first paint and nothing else.

Tier 2 — Read when you touch caching

High

Tier 3 — Small, focused, occasionally relevant

Medium

Tier 4 — One-liners and scaffolding

Low
Twenty-minute path

prerender.jsinitShouldSkipPrerendergetCacheableUrlreturnCachedPageforwardToPrerenderServergetIsCacheable. Six files, and you will understand the system.

Where To Make A Change

A lookup table from "I want to…" to the file to open.

I want to…OpenWatch out for
Add a new configuration option constants/defaultOptions.js plus the consuming helper Add it to the defaults even if the default is falsy, so it is discoverable.
Change who gets a cached page initShouldSkipPrerender Privacy-critical. Any loosening needs review; prefer a consumer skipChecker.
Make a query param or cookie affect the cache Nothing here — the consumer's queryParamsToRetain / cookiesToRetain Every added dimension multiplies cache entries and lowers hit rate.
Change the cache key format getCacheableUrl Invalidates every existing entry across all shops on deploy. Coordinate it.
Change how long pages live cacheSeconds for freshness; the literals in forwardToPrerenderServer and cachePageResponse for Redis TTL The TTLs (86400 / 259200) are hard-coded in three places.
Add a new HTML replacement for one shop The consumer's htmlReplacements option Consumer replacements run after the defaults, so ordering is on your side.
Add a replacement for every shop defaultReplacements Now every consumer's HTML must contain the marker or the edit silently does nothing.
Change what the prerender service is asked to do getRequestOptions The javascript snippet and renderType are a contract with that service.
Change what counts as a cacheable render getIsCacheable Loosening it risks caching broken pages; tightening it can stop caching altogether.
Change bot handling isGooglebotRequest, returnStaticCachedPage, removeScriptTags Removing JSON-LD would break rich results. Keep the ld+json exemption.
Fix a cache-stuck-in-progress bug setIsCurrentlyPrerenderingInCache + handleInvalidPrerender A flag that is set but never cleared is the usual cause. Consider a timestamp on the flag.
Add a client-side data helper src/helpers/ Remember it is not re-exported from the package root — document the deep import path.
Release your change PR to main with [major]/[minor]/[patch] in the title The version-bump block in cicd/build-legacy.yaml is currently commented out — verify how versioning is actually applied.
The golden rule for this repo

It is a shared library. A change ships to every Vodafone shop that bumps the version. Prefer adding an option over changing a default, and prefer a consumer hook (skipChecker, getUrlHash, htmlReplacements) over new library-wide behaviour.

Key Data Shapes

The four objects that everything else is expressed in terms of.

1. The user cache entry

{
  htmlContent: string,              // finished HTML, placeholders already replaced
  apiData: object,                  // captured window._hydratedData
  statusCode: number,               // 200 or 404
  timeOfCache: number,              // Date.now()
  isCurrentlyPrerendering: boolean, // single-flight marker
  buildHash: string                 // release stamp
}
// Redis TTL: 86400s

2. The bot cache entry

{
  htmlContent: string,   // scripts stripped, JSON-LD kept
  statusCode: number,
  timeOfCache: number
}
// Redis TTL: 259200s — note: no apiData, no buildHash, no flag

3. The placeholder / in-progress marker

{
  isCurrentlyPrerendering: true,
  timeOfCache: number
}
// The MISSING statusCode is the signal that there is no real page yet.

4. The prerender service response

{
  status: number,
  data: {
    content: string,               // raw rendered HTML
    prerenderData: {
      outstandingRequiredRequests: number,   // must be 0 to cache
      vfukTargetData?: object,               // optional Adobe Target payload
      [key: string]: any                     // whatever hydrate() stored
    }
  }
}

How they relate

prerender response
content + prerenderData
getIsCacheable
gate
replacePlaceholders
prerenderData inlined into HTML
bot entry
user entry
browser reads inlined _hydratedData, hydrate() skips the network

File Structure Tree

Red = read this first. index.js files are one-line re-exports; *.test.js files are omitted for readability except where noted.

lib-web-prerender/ ├── src/ │ ├── index.js // package entry → middleware/prerender │ ├── middleware/ │ │ ├── prerender.js // ★ THE ORCHESTRATOR │ │ ├── index.js │ │ ├── prerender.test.js // unit tests │ │ ├── prerender.integration.test.js // supertest + express │ │ ├── constants/ │ │ │ └── defaultOptions.js // every default value │ │ ├── utils/ │ │ │ └── MockRedis/ // in-memory Redis for local dev │ │ └── helpers/ │ │ ├── initShouldSkipPrerender/ // ★ privacy gate │ │ ├── getSessionCookie/ // IDM cookie lookup │ │ ├── fixPrerenderServerUrl/ // adds https:// (file: …ServiceUrl.js) │ │ ├── getCacheableUrls/ // both keys │ │ ├── getCacheableUrl/ // one key │ │ ├── getParamString/ // key fragments + cookie header │ │ ├── getFilteredQueryString/ // retained params only │ │ ├── getGeoLocationOverride/ // url → lat/long │ │ ├── isGooglebotRequest/ // user-agent substring │ │ ├── returnCachedPage/ // ★ 8-state cache machine │ │ ├── returnStaticCachedPage/ // bot cache read │ │ ├── forwardToPrerenderServer/ // ★ render + write both caches │ │ │ └── helpers/ │ │ │ └── getIsCacheable/ // ★ 3-part gate │ │ ├── buildPrerenderUrl/ // where to POST, what to render │ │ ├── getPrerenderableUrl/ // path + filtered query │ │ ├── getRequestOptions/ // axios config │ │ ├── isCurrentBuild/ // html.includes(buildHash) │ │ ├── defaultReplacements/ // built-in HTML contract │ │ ├── replacePlaceHolders/ // applies the list │ │ ├── removeScriptTags/ // strips scripts, keeps ld+json │ │ ├── cachePageResponse/ // the Redis write │ │ ├── setIsCurrentlyPrerenderingInCache/ // single-flight flag │ │ └── handleInvalidPrerender/ // clears the flag on failure │ ├── helpers/ // CLIENT-SIDE (browser) helpers │ │ ├── hydrate/ // ★ capture & reuse API data │ │ ├── requiredForPrerender/ // mark work as required │ │ ├── prerenderStaticPage/ // make API-free pages cacheable │ │ └── lazyHydrate/ // React lazy component reuse │ │ └── helpers/useDynamicImport/ // the import hook │ └── tools/jest/ │ ├── jest.config.js // 90% coverage thresholds │ ├── jest.setup.js // throws on unhandled rejections │ └── jestTrxProcessor.js // TRX output for Azure DevOps ├── cicd/ │ ├── build.yaml // pipeline entry, main + release/* │ ├── build-legacy.yaml // build + npm publish steps │ ├── merge-validator-dev.yaml // PR gate: yarn test │ └── renovate.yaml // weekly dependency scan ├── config/legacyBabel/ │ └── legacy.babel.config.js // lib/legacy build ├── .github/ │ └── CODEOWNERS // review ownership ├── babel.config.js // modern build; node target for middleware/ ├── package.json // scripts, deps, volta pins ├── .linkrc.js // local linking into web-shop-* ├── renovate.json / renovate.config.js // dependency automation ├── sonar-project.properties // ⚠ still the untouched template ├── .nvmrc / .npmrc / .npmignore ├── .prettierrc.json / .prettierignore ├── README.md // integration guide └── CHANGELOG.md // ⚠ empty
Two folders that do not exist despite being referenced

config/webpack/ and docs/website/ are named by npm scripts but are not in the repository. src/package/, referenced by sonar-project.properties and the jsdocs script, is also absent.

Environment Variables

Read directly by library code — not passed as options.

VariableRead inEffect
NODE_ENV prerender.js, buildPrerenderUrl, defaultReplacements local/development: inject MockRedis when no client given. local/development/test: render against http://localhost:8000. local: skip the CDN domain rewrite.
AUTH_COOKIE_PREFIX getSessionCookie Fallback auth cookie prefix when authCookiePrefix is not passed. Final fallback is eShop-auth.
PRERENDER_BOILERPLATE replacePlaceholders, buildPrerenderUrl When the string 'true': prefer boilerplatePlaceholder markers, and use PRERENDER_APP_ENDPOINT as the render target.
PRERENDER_APP_ENDPOINT buildPrerenderUrl Explicit app endpoint for the boilerplate path. Falls back to prerenderServerUrl.
String comparison, not truthiness

PRERENDER_BOILERPLATE is compared with === 'true'. Setting it to 1, TRUE or yes has no effect.

Variables such as PRERENDER_SERVICE_URL, REDIS_URL, TEALIUM and BUILD_HASH appear in the README examples, but they are read by the consuming app and passed in as options. The library never reads them.

Dependencies

Runtime dependencies

PackageVersionUsed for
agentkeepalive^4.6.0HttpsAgent in getRequestOptions, with rejectUnauthorized: false.
isbot^5.1.26Broad crawler detection in the geolocation branch of returnCachedPage.
uuid^11.1.0The default buildHash.
@babel/plugin-transform-runtime^7.26.10Listed as a runtime dependency because the compiled output references the Babel helpers.

Peer dependency

axios ^1.8.4 — the consumer must install it. This avoids two copies of axios in the same app and lets the consumer control the version.

Undeclared but used

qs

Imported by getCacheableUrl and getFilteredQueryString but absent from package.json. It resolves today because Express depends on it. Worth declaring explicitly.

jest.config.js also references @swc/jest as its transform and jestTrxProcessor.js requires jest-trx-results-processor and mkdirp, none of which appear in devDependencies. The active test run uses babel-jest.

Notable devDependencies

React 16.14 is deliberate

lazyHydrate is JSX, so React is needed to build and test it — but only as a dev dependency, since consumers bring their own React. React 16 keeps compatibility with the older shops.

Build & Babel

Two builds from one source

src/
babel.config.js
→ lib/
legacy.babel.config.js
→ lib/legacy/
postbuild: copy package.json, .npmignore, .npmrc into lib/
npm publish from ./lib

babel.config.js — the modern build

config/legacyBabel/legacy.babel.config.js

The same plugins but preset-env with no targets — i.e. transpile everything to ES5 — and no Node override. The output lands in lib/legacy/ for consumers that still need maximal compatibility.

What is excluded from the build

--ignore mocks/,src/**/**/**/*.test.js,.md

Mocks, tests and markdown are not published.

Testing

yarn test    # cross-env NODE_ENV=test jest

Coverage policy — src/tools/jest/jest.config.js

SettingValue
Thresholds90% statements, branches, functions, lines
Collected fromsrc/helpers/**, src/middleware/helpers/**, src/middleware/*.js
Reportershtml, json, json-summary, lcov, text-summary, cobertura
Output.coverage/, plus .testresults/test-results.trx
Transform@swc/jest (config) — babel-jest in practice

Two kinds of test

Unit — *.test.js beside each helper

Most helpers are pure functions of their arguments, which is precisely why the codebase is split into so many tiny modules. Each has a focused test file.

Integration — prerender.integration.test.js

Builds a real Express app with cookie-parser, mounts the middleware, and drives it with supertest. This is where cache hits, misses and header behaviour are verified end to end.

Files with no test file

forwardToPrerenderServer and handleInvalidPrerender are the notable gaps — both are on the critical path and both are exercised only indirectly.

Unhandled rejections are fatal

jest.setup.js installs process.on('unhandledRejection') and 'unhandledException' handlers that rethrow. Since the library fires several promises without awaiting them, a test that leaves one rejecting will fail loudly rather than silently.

CI/CD & Release

Azure DevOps pipelines under cicd/, using shared template repositories.

FileTriggerWhat it does
cicd/build.yaml main, release/* The entry pipeline. Extends build.yaml@yaml-pipeline-templates, injects build-legacy.yaml as its build steps, and also builds for release/, feature/ and PR refs. Runs on the build-agents-node pool.
cicd/build-legacy.yaml Included by the above Checks out with persisted credentials, sets up Node 22.14.0, runs yarn build, then npm publish from ./lib.
cicd/merge-validator-dev.yaml PR validation (trigger: none) Node setup then yarn test. This is the quality gate on a pull request.
cicd/renovate.yaml Cron 0 3 * * 0 — Sundays 03:00 Runs the shared Renovate template against renovate.config.js using the github-secret variable group.

Versioning

The README describes the intended flow: raise a PR to main whose title contains [major], [minor] or [patch], and a new package version is released automatically.

The bump block is commented out

In cicd/build-legacy.yaml the entire "VERSION BUMP STAGE" — reading the semver keyword from the merge commit, running npm version, committing and pushing the tag — is commented out. As checked in, the pipeline builds and publishes whatever version is already in package.json. Confirm with the owning team how versions are bumped today before relying on the README's description.

Dependency automation

Quality gates

Repo Tooling

FilePurposeNotes
.linkrc.js Local development linking into a consuming app compileYarnCmd: 'build', output lib flattened on link, source and target cleanup enabled, whitelist ['web-shop-'], copies package.json, nodemon watches ./src.
.nvmrc / volta Node version pinning Volta pins Node 18.20.5 and Yarn 1.22.21; CI uses Node 22.14.0.
.npmrc Private registry configuration Copied into lib/ by postbuild so publishing targets the internal registry.
.npmignore Publish exclusions Also copied into lib/.
.prettierrc.json / .prettierignore Formatting Applied on save / pre-commit in the usual Vodafone setup.
package.json → standard Linting config Declares jest globals and uses babel-eslint as the parser.
src/tools/jest/jestTrxProcessor.js Test reporting Writes .testresults/test-results.trx for Azure DevOps test tabs.
CHANGELOG.md Release history Currently empty despite being linked from the README.
.github/CODEOWNERS Review ownership Generated from the old ADO branch policies during the GitHub migration.
Migration in progress

Recent commits show a move from Azure DevOps to GitHub (Renovate config migration, branch reference updates, CODEOWNERS import, removal of catalog-info.yaml in favour of a central catalog). Expect some config files to reference both worlds for a while.

Gotchas & Sharp Edges

Real observations from the current code. None of these are hypothetical — each one is something a new contributor will otherwise discover the hard way.

The default buildHash breaks multi-instance cachingHigh impact

uuidv4() runs once per Node process. Behind a load balancer each instance has a different hash, so every instance treats the others' cache entries as "old build" and regenerates them. Effective cache hit rate collapses to near zero, silently — no errors, just slow pages.

Fix: always pass a deployment-stable buildHash (webpack bundle hash, image tag, release SHA).

isCurrentBuild passes for an empty hashSilent

html.includes('') is always true. If buildHash resolves to an empty string, the build check passes for everything and release invalidation stops working — while every log line looks healthy.

A stuck isCurrentlyPrerendering flag disables a URL for up to 24 hoursHigh impact

The flag has no timestamp of its own and no timeout. If the process dies between setting the flag and writing the result, or if handleInvalidPrerender itself throws (it has no try/catch and JSON.parse(null) yields null), the flag stays set. Every later request sees "someone is already rendering this" and falls through to CSR until the Redis TTL expires.

Symptom: one URL stops caching and never recovers while others are fine.

Workaround: delete that cache key in Redis.

options.buildHash is mutated on the shared objectConcurrency

if (options.getBuildHash) options.buildHash = await options.getBuildHash(req) writes to the closed-over options object, not a per-request copy. Harmless when getBuildHash is constant, but if it varies per request, concurrent requests can read each other's value.

Reordering queryParamsToRetain invalidates the whole cacheOperational

Cache-key fragments follow the array order, not the URL order or alphabetical order. Swapping two entries produces different keys for identical pages, so every entry becomes a cold miss on deploy.

alwaysPrerenderRoutes matches the full URL exactlyEasy to get wrong

It is Array.includes(req.url), so the query string is part of the comparison and there is no pattern matching. '/plans' will not match /plans?segment=business or /plans/.

Placeholder replacement is first-occurrence onlyTemplate bug

Only the prerenderServerUrlcdnDomain rule uses a global RegExp. If a marker such as var _hydratedData = {}; appears twice in your template, the second one survives and overwrites the injected data at runtime.

A missing cookie-parser throwsSetup

initShouldSkipPrerender reads req.cookies.basketId directly. Without cookie-parser mounted first, req.cookies is undefined and the property access throws.

A rejected requiredForPrerender permanently blocks cachingClient-side

The decrement happens after await dataCall(). If the call rejects, the catch path rejects the promise without decrementing, so outstandingRequiredRequests stays above zero and getIsCacheable rejects every render of that page. hydrate with isRequired: true has the same shape.

rejectUnauthorized: false on every prerender requestSecurity posture

TLS certificate verification is disabled for the call to the prerender service, and a fresh HttpsAgent is created per request — so the keep-alive pooling that agentkeepalive exists to provide is not actually reused. Acceptable for an internal host with a self-signed certificate, but worth being explicit about.

Fire-and-forget calls hide failuresObservability

forwardToPrerenderServer is never awaited. Its own try/catch catches everything and logs a console.warn. There is no metric, no error propagation and no retry beyond the next request — so a prerender service outage looks like "the site is a bit slower" unless someone is reading logs.

Hard-coded TTLs and a hard-coded localhost portConfig debt

86400 appears in forwardToPrerenderServer and cachePageResponse; 259200 in forwardToPrerenderServer; http://localhost:8000 in buildPrerenderUrl. None are options, so a shop running its dev server on a different port cannot prerender locally without the boilerplate env vars.

Naming and housekeeping inconsistenciesCosmetic
  • Folder fixPrerenderServerUrl/ contains fixPrerenderServiceUrl.js.
  • Folder replacePlaceHolders/ contains replacePlaceholders.js.
  • returnCachedPage.js starts with a stray ;;.
  • prerender.js imports returnStaticCachedPage by full file path while every other import uses the folder.
  • CHANGELOG.md is empty; sonar-project.properties is the untouched template.
  • Several npm scripts point at directories that do not exist.
  • followRedirect in the axios options is not an axios option (it would be maxRedirects).

Troubleshooting

Every log line from this library is prefixed [Prerender]. Grep for that first.

Log lines and what they mean

LogLevelMeaning
New route not in cache. Requesting prerender for…infoNormal cold miss. Expected on first visit.
request starting for…infoA render has been requested.
Updated bot cache for… / Updated cache for…infoSuccess. Both caches written.
Cache expired for… Requesting prerenderinfoNormal background refresh. The stale page was still served.
Cached page is old build…warnExpected once after a deploy. Persistent = unstable buildHash.
Initial prerender for URL … failed. Retrying…warnA placeholder was found with no result. Retry fired.
Request to prerender failed with status code…warnThe render service returned a non-cacheable status.
Required requests unsuccessful during prerender…warnoutstandingRequiredRequests was non-zero — the page was incomplete.
BuildHash not found in server renderwarnThe hash is not in the HTML. Check the template and the value.
Request to prerender failedwarnNetwork error or timeout calling the render service.
Failed to return cached page…warnThe cached entry could not be read or parsed.
Failed to set cached page for…warnRedis write failed. Response was unaffected.
Failed to remove script tags for static render.errorThe regex strip threw. The unmodified HTML was used.

Symptom → likely cause

Nothing is ever cachedCommon
  1. Is X-Prerender-Cache ever present on a response? If never, the middleware may be skipping.
  2. Is prerenderServerUrl set? A falsy value disables the library entirely and logs nothing.
  3. Is prerender() mounted before your renderer, and after cookieParser()?
  4. Do you have a basketId, JourneyID or session cookie in your browser? Try a private window.
  5. Look for BuildHash not found or Required requests unsuccessful — those are outright rejections.
One specific URL never cachesCommon

Most likely a stuck isCurrentlyPrerendering flag. Inspect the key in Redis; if it holds a marker with no statusCode, delete it. Second possibility: a required API call on that page rejects, so the counter never returns to zero.

Cache hit rate is poor in production but fine locallyCommon

Almost always the buildHash. Locally there is one process, so the uuid default is consistent; in production every instance disagrees. Look for a steady stream of Cached page is old build warnings — that is the fingerprint.

The page renders, then visibly re-fetches everythingClient-side

The hydration data did not make it in. Check that var _hydratedData = {}; appears in the template exactly once and byte-for-byte, that your API calls are wrapped in hydrate() with stable keys, and — if you are on the new boilerplate — that PRERENDER_BOILERPLATE=true is actually set to the string 'true'.

Assets 404 on a cached pageConfig

The CDN rewrite. Outside NODE_ENV=local, every occurrence of prerenderServerUrl is replaced with cdnDomain — or with an empty string if cdnDomain is unset, which can mangle asset URLs. Set cdnDomain explicitly.

Googlebot sees an empty pageSEO

The bot cache is only ever written during a user-driven prerender, never by the bot branch itself. A URL that only crawlers visit will have no static entry. Add it to alwaysPrerenderRoutes or drive traffic to warm it, and verify the content survives removeScriptTags — anything rendered by client-side JavaScript will be gone.

Users report seeing stale pricesBy design

Expected within the stale-while-revalidate window: an expired page is served once while its replacement is built. Lower cacheSeconds to shrink the window. If staleness lasts far longer, the background refresh is failing — check the warn-level logs.

Infinite loop / the app calls itself repeatedlySerious

The x-prerender header is not reaching the middleware. That header is what makes the middleware recognise the render service's own request and skip itself. Check that nothing between the render service and the app strips it.

Quick diagnostic

curl -sI https://<host>/<path> | grep -i x-prerender-cache. Present means you were served from cache. Absent means the middleware skipped, missed, or deliberately deferred — and the [Prerender] logs will say which.

Glossary

Prerender

Building a page's finished HTML ahead of time, on a server, instead of in the visitor's browser.

Prerender service

A separate deployment running a headless browser. Accepts POST /render with a URL and returns the finished HTML plus captured data. Not part of this repo.

Client-side rendering (CSR)

The normal path: the browser downloads JavaScript, calls APIs, and draws the page itself. The fallback whenever prerender is skipped or misses.

Hydration

Giving the browser the API data the prerender already fetched, so the interactive app boots without repeating those calls.

Middleware

A function Express runs on each request. It either responds or calls next() to pass the request on.

Cache key / cacheable URL

The string identifying one cached page variant: environment prefix + path + declared query params and cookies.

Bot cache / static page

The static- prefixed variant with scripts stripped and JSON-LD kept, served to Googlebot and kept for 72 hours.

Stale-while-revalidate

Serve the expired copy immediately, build the replacement in the background. Nobody waits; content is briefly slightly old.

Single flight

Ensuring only one render is in progress per key at a time — here via the isCurrentlyPrerendering marker.

Build hash

An identifier for the current release, stamped into cached pages so a deploy automatically invalidates them.

TTL

Time to live — how long Redis physically keeps a key. Distinct from cacheSeconds, which governs logical freshness.

IDM / assurance level

Vodafone's identity system and its measure of how strongly a visitor is authenticated. Level 3 or above means logged in — never serve a shared page.

Journey

An in-progress purchase or upgrade flow, evidenced by a basketId, JourneyID or transfer transaction cookie.

Tealium / utag

Vodafone's tag-management platform. utag.sync.js must load synchronously in <head>, which is why it is injected by placeholder replacement.

JSON-LD

Structured data embedded in a <script type="application/ld+json"> tag. Preserved in bot pages because search engines build rich results from it.

x-prerender header

Set on requests originating from the prerender service. Its presence makes the middleware skip itself, breaking the render loop.

Boilerplate

The newer Vodafone app scaffold. PRERENDER_BOILERPLATE=true switches the library to its marker and endpoint conventions during migration.

outstandingRequiredRequests

A counter on window._hydratedData. Must be 0 when the snapshot is taken, otherwise the render is judged incomplete and is not cached.