Skip to content

Document headers and caching

Cache guest HTML at the CDN with a short freshness lifetime and a bounded stale-while-revalidate window. Bypass both cache reads and writes whenever the session cookie or Authorization header is present. Authenticated documents use private, no-store; cache their underlying data with appropriate user or tenant keys instead of sharing rendered pages.

Only opt URLs into a shared cache when their HTML, loader data and serialized custom state are public and independent of unkeyed inputs. Put locale or other public variants in the URL, or design a separate cache key. Login, account, cart and mutation routes should bypass the page cache. Purge guest entries when content must disappear sooner than their freshness plus stale window.

private excludes shared caches; no-store also excludes browser storage. Setting Set-Cookie alone does not prohibit caching, so the document helper applies a privacy default explicitly. See RFC 9111. Stale-while-revalidate permits a bounded stale response while refresh runs in the background; it does not extend freshness. See RFC 5861.

HTTP helpers

Import the server-side @lomray/vite-ssr-boost/http entry. It uses Fetch primitives, has no Node-only imports, and works with either renderer. Keep it out of browser entries. All snippets on this page are compiled fixtures; the helper, Express, Worker and Nginx examples also have executable tests.

cacheControl(policy)

The boolean fields are public, private, noStore, noCache, mustRevalidate, and immutable. The duration fields are maxAge, sMaxAge, staleWhileRevalidate, and staleIfError, in non-negative safe integer seconds. False and undefined fields are omitted; an empty object returns an empty string. Invalid types, unknown fields, public plus private, and contradictory storage/revalidation directives throw TypeError.

These policies and ordered rules are shared by the following examples:

ts
import { cacheControl } from '@lomray/vite-ssr-boost/http';
import type { IDocumentHeaderRule } from '@lomray/vite-ssr-boost/http';

export const sessionCookie = 'session';
export const freshSeconds = 30;
export const staleSeconds = 60;
export const guestPolicy = cacheControl({
  public: true,
  maxAge: freshSeconds,
  staleWhileRevalidate: staleSeconds,
});
// Use this alternative when shared-cache freshness differs and stale serving is unnecessary.
export const sharedPolicy = cacheControl({ public: true, maxAge: 0, sMaxAge: 30 });
export const rules: IDocumentHeaderRule[] = [
  { when: () => true, set: { 'Cache-Control': cacheControl({ private: true, noStore: true }) } },
  {
    when: ({ request, url }) =>
      ['GET', 'HEAD'].includes(request.method) && url.pathname === '/guest',
    set: { 'Cache-Control': guestPolicy },
  },
];

The SWR policy uses max-age, not s-maxage: RFC 9111 gives s-maxage the semantics of proxy-revalidate, which prevents shared caches from serving stale responses before validation. The sharedPolicy alternative gives browsers a zero freshness lifetime and shared caches 30 seconds when stale serving is unnecessary. The builder permits sMaxAge together with stale extensions, but does not change the cache's interpretation. Cloudflare documents this restriction in its revalidation guide.

documentHeaders(rules, options?)

The helper returns a function taking the Fetch render context and returning a fresh Headers object. Each rule has when({ request, url, isBot, hasCookie, routerContext }) and set: HeadersInit. All matching rules run in array order. Later rules replace ordinary fields; every Set-Cookie value appends independently. Existing hook headers are the starting point. Guests without a matching policy keep those headers.

isBot is a User-Agent heuristic matching bot, crawler, spider or crawling; it is not an authentication check. hasCookie(name) compares exact, case-sensitive cookie names and treats empty values as present, without decoding the values.

Set sessionCookie to your application's cookie name. After the rules, Set-Cookie on the resulting document, Authorization on the request, or that session cookie forces private, no-store. Configure the same cookie name at the CDN. A deliberate protectPrivate: false override disables this final protection; public responses with Set-Cookie or the configured session cookie then trigger the development SSR_BOOST_CACHE_PRIVATE_LEAK diagnostic.

The helper removes the Cookie token from Vary, preserving other tokens. It never emits Vary: Cookie: varying on entire cookie strings fragments a CDN cache by session IDs and unrelated tracking cookies. Use a session-presence bypass instead. Do not use these URL-only recipes for pages personalized by other cookies; bypass those pages too. Removing Vary does not make personalized content public.

Use the handler's documentHeaders option to apply the rules automatically after onShellReady in prepareHtmlResponse, before document metadata is committed. It is opt-in, including an empty rule list to enable only the privacy defaults. Explicit loader/action and server redirects keep their existing mergeResponseHeaders precedence; short-circuit onRequest responses and fatal shell errors are not rule targets. Give those responses their own cache headers.

For manual use in onRouterReady or onShellReady:

ts
import { documentHeaders } from '@lomray/vite-ssr-boost/http';
import type { ICoreRenderOptions } from '@lomray/vite-ssr-boost/core/render';
import { rules, sessionCookie } from './policy';

const finalize = documentHeaders(rules, { sessionCookie });

// Equivalent to the handler's documentHeaders option. Prefer onShellReady so that
// cookies written by earlier hooks are visible when the privacy default runs.
export const onShellReady: ICoreRenderOptions['onShellReady'] = ({ context }) => {
  context.response.headers = finalize(context);

  return {};
};

The shell stage sees cookies written by earlier hooks. If you use the router stage, recompute after any later header changes. Headers and statuses cannot change once the streamed shell has been sent.

copyLoaderHeaders(routerContext, { allow })

Rendered loader/action headers remain separate from document headers until explicitly copied. This helper returns a new Headers object containing only the allowlist. It visits matched routes from root to leaf, loader before action at the same route. The first ordinary value wins, so the shallowest route controls Cache-Control and other allowed fields. All Set-Cookie values append in traversal order, including cookies with commas in Expires. Unmatched route headers are ignored. Content-Type is never copied, even when allowed: loader JSON is not the HTML document.

The complete application factory below demonstrates copying without discarding existing hook headers. Document rules run afterwards and override copied ordinary fields. Supply the Node or edge renderer as shown in the recipes. This example renders server HTML only; an interactive application supplies its built browser script and static assets through its existing shell/asset pipeline. The cookie check is a demonstration of presence, not session validation.

tsx
import createHandler from '@lomray/vite-ssr-boost/core/handler';
import type { TRenderToStream } from '@lomray/vite-ssr-boost/core/render';
import { copyLoaderHeaders } from '@lomray/vite-ssr-boost/http';
import React from 'react';
import { createStaticHandler, useLoaderData } from 'react-router';
import { rules, sessionCookie } from './policy';

const Page = () => <main>{useLoaderData<{ message: string }>().message}</main>;

/** A complete server-rendered example; wire real authentication into the loader. */
export const createPageHandler = (renderToStream: TRenderToStream) =>
  createHandler(
    {
      createApp: (children) => children,
      handler: createStaticHandler([
        {
          path: '*',
          Component: Page,
          loader: ({ request }) => {
            const authenticated =
              request.headers.has('Authorization') ||
              /(?:^|;)\s*session\s*=/.test(request.headers.get('Cookie') ?? '');

            return Response.json({ message: authenticated ? 'Account page' : 'Guest page' });
          },
        },
      ]),
      renderToStream,
    },
    {
      getHtml: () => ({
        header: '<!doctype html><html><body><div id="root">',
        footer: '</div></body></html>',
      }),
      sessionCookie,
      documentHeaders: rules,
      onRouterReady: ({ context }) => {
        const copied = copyLoaderHeaders(context.routerContext!, {
          allow: ['Cache-Control', 'Set-Cookie'],
        });

        // Preserve existing hook metadata and independent cookies.
        copied.forEach((value, name) => {
          if (name !== 'set-cookie') context.response.headers.set(name, value);
        });
        copied
          .getSetCookie()
          .forEach((cookie) => context.response.headers.append('Set-Cookie', cookie));

        return {};
      },
    },
  );

The managed CLI entry passes the same policy options through its init result:

tsx
import entryServer from '@lomray/vite-ssr-boost/adapters/express/entry';
import React from 'react';
import { rules, sessionCookie } from './policy';

export default entryServer(
  ({ children }) => children,
  [{ path: '/guest', Component: () => <main>Public guest content</main> }],
  { init: () => ({ documentHeaders: rules, sessionCookie }) },
);

Cloudflare Worker

This Worker caches /guest by the full URL, including the query string. Credentialed requests bypass lookup and storage. Guest renders receive only the URL and a fixed Accept header, so unkeyed cookies or headers cannot affect the shared representation. HEAD, mutations, ranges, validators and explicit request cache directives go to the origin. Only successful responses with the exact guest policy, no cookies and no Vary are stored.

The Cache API does not implement stale-while-revalidate. This wrapper stores the body for 90 seconds, returns the original 30-second policy and Age to clients, and refreshes during the next 60 seconds with waitUntil. At expiry it waits for a fresh response. Refresh errors cannot extend the stale window. Concurrent refreshes are coalesced within one isolate. Cache API storage is local to a data center and does not use tiered caching.

ts
import type { TSsrHandler } from '@lomray/vite-ssr-boost/core/types';
import { freshSeconds, guestPolicy, staleSeconds } from './policy';

interface IBackgroundContext {
  waitUntil: (promise: Promise<unknown>) => void;
}

/** URL-only, bounded SWR cache for the /guest representation in app.tsx. */
export const createGuestCache = (
  origin: TSsrHandler,
  cache: Pick<Cache, 'match' | 'put' | 'delete'>,
  now: () => number = Date.now,
) => {
  const refreshing = new Map<string, Promise<void>>();
  const storedAt = 'X-Guest-Cache-Stored-At';

  return async (request: Request, context: IBackgroundContext): Promise<Response> => {
    const url = new URL(request.url);
    const cookie = request.headers.get('Cookie') ?? '';

    if (
      request.method !== 'GET' ||
      url.pathname !== '/guest' ||
      /(?:^|;)\s*session\s*=/.test(cookie) ||
      [
        'Authorization',
        'Range',
        'If-None-Match',
        'If-Modified-Since',
        'Cache-Control',
        'Pragma',
      ].some((name) => request.headers.has(name))
    ) {
      return origin(request);
    }

    // Query strings stay in the key. Guest output must depend only on this URL.
    url.hash = '';
    const key = new Request(url, { method: 'GET' });
    const cached = await cache.match(key);
    const timestamp = Number(cached?.headers.get(storedAt) ?? NaN);
    const age = Math.max(0, Math.floor((now() - timestamp) / 1000));

    const refresh = async (): Promise<Response> => {
      // Drop unkeyed cookies and headers before rendering the shared representation.
      const response = await origin(new Request(url, { headers: { Accept: 'text/html' } }));

      if (
        response.status === 200 &&
        response.headers.get('Cache-Control') === guestPolicy &&
        !response.headers.has('Set-Cookie') &&
        !response.headers.has('Vary')
      ) {
        const stored = new Response(response.clone().body, response);

        stored.headers.set(storedAt, String(now()));
        // Cache API has no native SWR: retain the body for the entire bounded window.
        stored.headers.set('Cache-Control', `public, max-age=${freshSeconds + staleSeconds}`);
        await cache.put(key, stored);
      } else {
        await cache.delete(key);
      }

      return response;
    };

    if (cached && Number.isFinite(timestamp) && age < freshSeconds + staleSeconds) {
      if (age >= freshSeconds && !refreshing.has(url.href)) {
        const pending = refresh()
          .then((response) => response.body?.cancel())
          .catch(() => undefined)
          .finally(() => refreshing.delete(url.href));

        refreshing.set(url.href, pending);
        context.waitUntil(pending);
      }

      const response = new Response(cached.body, cached);

      response.headers.delete(storedAt);
      response.headers.set('Cache-Control', guestPolicy);
      response.headers.set('Age', String(age));

      return response;
    }

    // A tee's cancellation can wait for the cache's retained branch; do not block refresh.
    void cached?.body?.cancel().catch(() => undefined);

    return refresh();
  };
};

Worker entry (bundle with the workerd and worker resolution conditions):

ts
import renderToStream from '@lomray/vite-ssr-boost/edge/render-to-stream';
import { createPageHandler } from './app';
import { createGuestCache } from './guest-cache';

const origin = createPageHandler(renderToStream);
let cached: ReturnType<typeof createGuestCache> | undefined;

export default {
  fetch(
    request: Request,
    _env: unknown,
    context: { waitUntil: (promise: Promise<unknown>) => void },
  ) {
    cached ??= createGuestCache(origin, (caches as CacheStorage & { default: Cache }).default);

    return cached(request, context);
  },
};

Equivalent Cache Rules for an origin server

Use these settings instead of the Worker wrapper when Cloudflare fronts the Express origin directly. The origin must keep the same public representation contract. Create the guest eligibility rule first and the bypass rule last, since matching later rules override earlier settings. Scope both rules to your hostname.

RuleMatching requestsSettings
Guest pageGET or HEAD, path exactly /guestEligible for cache; respect origin Cache-Control and bypass if absent; browser TTL respects origin; full URL key including query; serve stale while revalidating enabled
CredentialsCookie string contains session= or Authorization header is presentBypass cache
Other requestsOther paths, methods, Range, conditional headers or explicit request cache directivesBypass cache

The cookie substring match intentionally over-bypasses lookalike names; it does not miss an empty session= value. Use the rules editor's header-presence condition for Authorization, including empty values. Do not override origin private/no-store, strip Set-Cookie, or force an edge TTL on credentialed responses. The origin guest policy supplies max-age and stale-while-revalidate; using s-maxage disables stale serving on Cloudflare. See Cache Rules settings and cookie bypass.

Express behind Nginx

Create the Express app with the Node renderer:

ts
import adapterExpress from '@lomray/vite-ssr-boost/adapters/express';
import renderToStream from '@lomray/vite-ssr-boost/node/render-to-stream';
import express from 'express';
import { createPageHandler } from './app';

export const app = express();

app.disable('etag');
app.use(adapterExpress(createPageHandler(renderToStream)));

// The launcher calls start(); tests bind an ephemeral port on app instead.
export const start = () => app.listen(3000, '127.0.0.1');

Run this launcher with your server TypeScript build/runtime:

ts
import { start } from './express';

start();

Run Nginx with this configuration in a writable prefix directory. It listens on 8080 and proxies to Express on localhost:3000; configure production TLS and hostnames for your deployment. Guest HTML is buffered into the proxy cache. The private path always reaches Express. Both bypass and no-cache directives matter: the former prevents reading a guest hit; the latter prevents storing a private response.

nginx
worker_processes 1;
pid nginx.pid;
error_log stderr;
events { worker_connections 128; }
http {
    access_log off;
    proxy_temp_path proxy_temp;
    proxy_cache_path cache levels=1:2 keys_zone=guest:10m max_size=1g inactive=5m;

    # Empty and "0" session values still mean cookie presence.
    map $http_cookie $session_present {
        default 0;
        "~(^|;)[[:space:]]*session[[:space:]]*=" 1;
    }
    map $http_authorization $authorization_present {
        default 1;
        "" 0;
    }

    server {
        listen 8080;
        server_name localhost;
        location = /guest {
            proxy_pass http://127.0.0.1:3000;
            proxy_set_header Host $host;
            proxy_cache guest;
            proxy_cache_key "$scheme://$host$request_uri";
            proxy_cache_bypass $cookie_session $session_present $authorization_present;
            proxy_no_cache $cookie_session $session_present $authorization_present $upstream_http_set_cookie;
            proxy_cache_lock on;
            proxy_cache_background_update on;
            # Origin max-age and stale-while-revalidate bound freshness and stale use.
            # Keep Nginx's handling of Cache-Control, Set-Cookie and Vary enabled.
            add_header X-Page-Cache $upstream_cache_status always;
        }
        location / {
            proxy_pass http://127.0.0.1:3000;
            proxy_set_header Host $host;
        }
    }
}

$cookie_session alone treats empty and 0 values as false, so the additional raw cookie presence map covers those values. Nginx honors origin Cache-Control, Set-Cookie and Vary by default. Background update uses the origin's bounded SWR window; avoid an unconditional proxy_cache_use_stale updating override that could outlive it. See the Nginx proxy module.

Conditional requests and streaming

conditionalRequest(request, { etag?, lastModified? }) returns a bodyless 304 for a matching GET or HEAD, otherwise undefined. ETags must be quoted (optionally W/); Last-Modified accepts a Date or date string. It supports weak ETag comparison, comma-separated tags and * for an existing representation. If-None-Match takes precedence over If-Modified-Since, including when no ETag matches. Modification times compare at HTTP's whole-second precision. Invalid supplied validators throw; invalid request validators are ignored. Other methods never return 304.

Call it only for an existing successful representation after selecting and authorizing that representation. It does not implement mutation preconditions such as If-Match. Retain the 200 response's Cache-Control, Vary and Content-Location when applicable. The following buffered response uses an application-supplied version covering both article and template revisions:

ts
import { cacheControl, conditionalRequest } from '@lomray/vite-ssr-boost/http';

/** A public, existing representation whose version includes every output dependency. */
export const bufferedPage = (request: Request): Response => {
  const html = '<!doctype html><main>Published article revision 7</main>';
  const validators = {
    etag: 'W/"article-7-template-2"',
    lastModified: 'Tue, 01 Sep 2026 12:00:00 GMT',
  };
  const headers = new Headers({
    'Cache-Control': cacheControl({ public: true, maxAge: 30 }),
    'Content-Type': 'text/html',
    ETag: validators.etag,
    'Last-Modified': validators.lastModified,
  });
  const unchanged = conditionalRequest(request, validators);

  if (unchanged) {
    // A 304 must retain the cache policy and any Vary/Content-Location of the 200.
    unchanged.headers.set('Cache-Control', headers.get('Cache-Control')!);

    return unchanged;
  }

  return new Response(request.method === 'HEAD' ? null : html, { headers });
};

A streamed body cannot be hashed before sending it. Setting isStream: false waits for React and loader promises, but the Fetch response still exposes a body stream; buffer/consume that body yourself before hashing it and constructing a conditional response. This helper never reads a stream. For streaming applications, use an application-supplied representation version known before rendering, and make the conditional decision in a buffered/non-streaming path before committing the shell. The version must cover every output dependency, including serialized state and personalization. A build ID alone is insufficient for changing page data.