DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A personalisation hint in the root layout pulled 100KB of catalogue into every page

CogniPrep remembers what you first arrived for. If your first page was a specific test provider's hub, the dashboard greets you with that provider rather than a generic start screen. If you arrived through the assessment centre page, you land on the assessment centre exercises.

It is about as small a feature as exists. One localStorage key, written once, read once. It still managed to become the most expensive thing on pages that have nothing to do with it, and the reason is worth writing down because it generalises to any component you mount in a root layout.

The shape of the feature

const STORAGE_KEY = 'cogniprep:entry-intent';
const ENTRY_INTENT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;

export function recordEntryIntent(intent) {
  if (typeof window === 'undefined') return;
  try {
    if (readEntryIntent()) return;   // first-touch wins
    localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...intent, at: Date.now() }));
  } catch {
    // disabled or full localStorage must never break a render
  }
}
Enter fullscreen mode Exit fullscreen mode

Three properties, each deliberate:

First-touch, never overwritten. The earliest signal in a visit is the one that sticks. Someone who arrives on a provider page and then browses four others still came for the first one. Later browsing is exploration, not intent.

Stale after 30 days. Without an expiry, a visit in March pins the dashboard of a returning visitor in September. A personalisation hint that outlives the reason for it is worse than no hint.

Every access wrapped in try/catch. Private browsing modes and full quotas throw on localStorage. A greeting is not worth a blank page.

It gates nothing. It carries a known provider slug or a fixed sentinel and nothing else. That is the whole threat model, and it is why the value can live in plain client storage at all.

The part that cost 100KB

The component that records this is mounted in the root layout, because intent can be expressed on any page, including a click three navigations into a visit.

Anything a root-layout client component imports lands in the first-load JavaScript of every page on the site.

To recognise /games/<something> as a provider hub, the tracker needs to know which slugs are providers. That list used to be derived inside the game constants module:

export const ALL_PROVIDERS = Object.keys(PROVIDER_META);
Enter fullscreen mode Exit fullscreen mode

Correct, and free on the server. On the client it is a disaster, because that module is 4,400 lines and the bulk of it is the game catalogue. Importing the provider list imported the catalogue. So roughly 100KB of game metadata shipped in the first load of the blog, the pricing page and the login page, to support a greeting none of them can trigger.

The fix is a file containing the slugs and nothing else:

const PROVIDER_IDS: Record<AssessmentProvider, true> = {
  'arctic-shores': true,
  hirevue: true,
  shl: true,
  // ...24 in total
};
Enter fullscreen mode Exit fullscreen mode

Two details make that file better than an array literal. Record<AssessmentProvider, true> forces it to name every member of the union in the types module, so adding a provider to the type without adding it here is a compile error rather than a slug that silently stops being recognised. And the key order is load-bearing (it is the order providers are numbered on the landing page), so there is a unit test asserting it still matches the order of the metadata object it was split out of.

The case that could not be split

Provider hubs live at /games/<provider>; individual games live at /games/<gameId>. Recognising a game id means mapping it back to its provider, and that mapping only exists in the catalogue. Splitting the slugs out does not help.

So that case moved to where the answer is already known for free. The game route is a Server Component that has looked its entry up anyway, so it renders a tiny recorder and hands the slug down as a prop:

<EntryIntentRecorder provider={game.provider} />
Enter fullscreen mode Exit fullscreen mode

The client component receives a string. It does not import anything that can resolve one. This is the generalisable move: when a client component needs a lookup, check whether a server component upstream has already done that lookup, and pass the answer rather than the means of computing it.

The recorder takes a bare slug rather than an object for the same reason: the smaller the prop, the less there is to import on either side.

Crossing the auth boundary

localStorage does not exist on the server, and the redirect after sign in happens on the server. So at the moment a visitor starts signing up, the client copies the intent into a short-lived cookie, and the auth callback reads it there.

The encoder and the cookie name live in the same module as the readers, so the writer and the reader cannot disagree about either. The value is never used as a path: it selects between a fixed set of internal destinations, with any provider slug validated against the catalogue before it resolves to a route. An attacker-supplied cookie can only ever fall through to the default. That is the difference between "we store a destination" and "we store a hint that selects a destination", and only the second one is safe to accept from a browser.

See it

Open cogniprep.app/games/shl in a fresh browser profile, then in the console:

localStorage.getItem('cogniprep:entry-intent')
// {"kind":"provider","id":"shl","at":1789036080966}
Enter fullscreen mode Exit fullscreen mode

Now click through to a different provider, for example cogniprep.app/games/arctic-shores, and read the key again. It still says shl. That is first-touch working.

I got a nicer demonstration than I planned while checking this. My browser profile had visited an Arctic Shores page 17 days earlier, so loading the SHL page today returned:

{"kind":"provider","id":"arctic-shores","at":1789036080966}
Enter fullscreen mode Exit fullscreen mode

17 days old, inside the 30 day window, and untouched by the newer visit. Set the timestamp back beyond 30 days and reload, and the next page you land on claims the slot instead.

The takeaway

Root layouts are shared by every route, so their client components have the widest import blast radius in the app. A feature can be genuinely tiny and still be expensive, if the thing it imports is not. Check what your always-mounted components pull in, and when the answer is "a catalogue", find out whether a server component upstream already knows the answer.

Top comments (0)