Separate Baselines for Logged-In and Anonymous Users

This how-to is part of the Segmenting Baselines by Page Type reference, and it addresses the segmentation split that trips up the most teams: authentication. The same URL served to a signed-out visitor and to a signed-in member is, in performance terms, two different pages. If your budget pools both into a single number, the gate either passes regressions on the heavy authenticated view or blocks perfectly healthy anonymous traffic. The fix is to measure each session state, hold two baselines, and gate against the baseline that matches the session under test.

The goal here is concrete: by the end you will have a Lighthouse run that carries an authenticated cookie, a second run with no cookie, two stored baselines, a CI job that logs in before it measures, and a Real User Monitoring pipeline that tags every beacon with the session state so your field percentiles never mix the two populations.

Why Authentication Changes the Weight of a Page

Three forces make an authenticated view heavier, and all three are structural rather than incidental. First, personalization: a logged-in shell renders a name, a cart, saved items, recommendations, and account widgets. Each is data-dependent markup that cannot be baked into the static template, so the browser waits on a user-specific fetch before the largest contentful element settles. Second, cache locality: anonymous HTML is usually served from a shared CDN edge cache with a warm hit rate, while an authenticated response is marked Cache-Control: private, no-store and rendered per request at the origin. The logged-in visitor pays origin latency and misses the edge entirely. Third, payload weight: session code — auth SDKs, feature-flag clients, analytics that only fire for known users, and hydration state for personalized components — ships only to signed-in sessions, so the third-party and first-party script weight diverges sharply.

The combined effect is not a rounding error. On a mid-range mobile device throttled to Fast 3G, a representative catalog page might hold an LCP P75 of 3,500 ms anonymous versus 4,600 ms logged-in, and an INP P75 of 180 ms anonymous versus 310 ms logged-in once the personalized event handlers hydrate. Averaging those into one 4,050 ms LCP target means the anonymous population, which is often the majority of traffic and the conversion-critical one, silently drifts upward without ever tripping the gate.

Transfer weight by session type An anonymous session transfers roughly 420 KB while a logged-in session transfers roughly 980 KB for the same URL. Transfer weight, same URL, mid-range mobile 1000 KB 500 KB 0 420 KB HTML + JS Anonymous 980 KB + personalization + auth SDK Logged-in 2.3x
The authenticated view more than doubles the transfer weight, so a pooled budget hides regressions in whichever population the average under-weights.

Measuring Both Sessions With Lighthouse

Lighthouse measures whatever session the browser presents. To capture the authenticated view, you inject a valid session cookie (or Authorization header) before navigation; to capture the anonymous view, you run the same URL with a clean profile and no credentials. The cleanest approach is Puppeteer scripting so the login is deterministic and never depends on a UI form that might change. Keep the credentials in CI secrets, never in the repo.

// auth-gather.js — obtain a session cookie via the login API, hand it to Lighthouse
import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';

const ORIGIN = process.env.TARGET_ORIGIN;      // https://staging.example.com
const TARGET = `${ORIGIN}/account/dashboard`;

async function getSessionCookie() {
  const res = await fetch(`${ORIGIN}/api/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email: process.env.PERF_USER,
      password: process.env.PERF_PASS,
    }),
  });
  if (!res.ok) throw new Error(`login failed: ${res.status}`);
  const raw = res.headers.get('set-cookie');
  const match = raw && raw.match(/session=([^;]+)/);
  if (!match) throw new Error('no session cookie returned');
  return match[1];
}

async function run() {
  const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless=new'] });
  const session = await getSessionCookie();
  const flags = {
    port: chrome.port,
    onlyCategories: ['performance'],
    extraHeaders: { Cookie: `session=${session}` },
    formFactor: 'mobile',
    screenEmulation: { mobile: true, width: 412, height: 823, deviceScaleFactor: 2.6 },
    throttling: { rttMs: 150, throughputKbps: 1638, cpuSlowdownMultiplier: 4 },
  };
  const { lhr } = await lighthouse(TARGET, flags);
  await chrome.kill();
  const m = lhr.audits;
  console.log(JSON.stringify({
    profile: 'logged-in',
    lcp: m['largest-contentful-paint'].numericValue,
    tbt: m['total-blocking-time'].numericValue,
    cls: m['cumulative-layout-shift'].numericValue,
  }));
}

run().catch((e) => { console.error(e); process.exit(1); });

Run the identical script a second time with the extraHeaders block removed to produce the anonymous profile. Both runs must share the same throttling and screen emulation so the only variable is the session, matching how you calibrate CPU throttling for CI runners. Because a single Lighthouse pass is noisy, take the median of at least five runs per profile before you compare anything to a baseline.

Two-profile measurement flow The runner logs in once, then measures the authenticated and anonymous views on separate branches. CI runner starts job POST /login get cookie Lighthouse cookie set (auth) Lighthouse clean profile (anon)
One login yields the cookie; the job then measures the authenticated and anonymous views as two independent profiles feeding two baselines.

Maintaining Two Baselines

Store the profiles as separate baseline records keyed by session state, never as one merged file. Each baseline holds the accepted median plus a tolerance band, and the gate compares like to like: the authenticated run is checked against the authenticated baseline, the anonymous run against the anonymous baseline. Because the authenticated view legitimately carries more weight, its ceilings are numerically higher, and that asymmetry is the whole point — it stops the heavy view from borrowing headroom from the light one. These records feed the same promotion workflow you use for historical baseline calibration, just doubled per URL.

Profile Metric Baseline (median) Tolerance Gate ceiling Context
Anonymous LCP P75 3500 ms +8% 3780 ms Mid-range mobile, Fast 3G
Logged-in LCP P75 4600 ms +8% 4968 ms Mid-range mobile, Fast 3G
Anonymous INP P75 180 ms +12% 202 ms Mid-range mobile, Fast 3G
Logged-in INP P75 310 ms +12% 347 ms Mid-range mobile, Fast 3G
Anonymous TBT 250 ms +15% 288 ms Desktop CI runner, 4x CPU
Logged-in TBT 520 ms +15% 598 ms Desktop CI runner, 4x CPU

The tolerance band absorbs run-to-run noise; set it from observed variance rather than a guessed percentage, the way you would when choosing between P75 and P90 targets. A stored baseline JSON that CI reads is trivial to keep in the repo or an artifact store.

{
  "url": "/account/dashboard",
  "profiles": {
    "anonymous": { "lcpP75Ms": 3500, "inpP75Ms": 180, "tbtMs": 250, "tolerancePct": 8 },
    "logged-in": { "lcpP75Ms": 4600, "inpP75Ms": 310, "tbtMs": 520, "tolerancePct": 8 }
  },
  "device": "mid-range-mobile",
  "connection": "fast-3g"
}
Baseline routing by session state A measured run is compared against the baseline matching its session state, never a pooled baseline. session state? has cookie no cookie Logged-in baseline LCP ceiling 4968 ms Anonymous baseline LCP ceiling 3780 ms
The gate branches on session state so each run is judged against the baseline built from the same population.

A CI Job That Authenticates

Wire both profiles into the pull-request workflow so every change is judged against both baselines before merge. The job below extends the standard pattern from running Lighthouse CI on every pull request by adding the login step and running the gather script twice with different environments. Secrets carry the throwaway performance-test account; that account should be seeded with realistic personalization data (a populated cart, saved items) so the authenticated view is representative and not an empty shell.

name: perf-gate-auth-anon
on: pull_request
jobs:
  measure:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        profile: [anonymous, logged-in]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - name: Measure ${{ matrix.profile }}
        env:
          TARGET_ORIGIN: ${{ vars.STAGING_ORIGIN }}
          PERF_USER: ${{ secrets.PERF_USER }}
          PERF_PASS: ${{ secrets.PERF_PASS }}
          PROFILE: ${{ matrix.profile }}
        run: node auth-gather.js > result-${{ matrix.profile }}.json
      - name: Gate against ${{ matrix.profile }} baseline
        run: node gate.js baselines.json result-${{ matrix.profile }}.json ${{ matrix.profile }}
      - uses: actions/upload-artifact@v4
        with:
          name: perf-${{ matrix.profile }}
          path: result-${{ matrix.profile }}.json

The gate script itself is small: read the baseline for the named profile, compare the measured medians against the ceiling, and exit non-zero on a breach so the check fails.

// gate.js baselines.json result.json <profile>
import { readFileSync } from 'node:fs';

const [baseFile, resultFile, profile] = process.argv.slice(2);
const base = JSON.parse(readFileSync(baseFile, 'utf8')).profiles[profile];
const got = JSON.parse(readFileSync(resultFile, 'utf8'));
const tol = 1 + base.tolerancePct / 100;

const checks = [
  ['LCP', got.lcp, base.lcpP75Ms * tol],
  ['TBT', got.tbt, base.tbtMs * tol],
];

let failed = false;
for (const [name, actual, ceiling] of checks) {
  const ok = actual <= ceiling;
  console.log(`${profile} ${name}: ${Math.round(actual)} ms vs ceiling ${Math.round(ceiling)} ms ${ok ? 'PASS' : 'FAIL'}`);
  if (!ok) failed = true;
}
process.exit(failed ? 1 : 0);

Segmenting RUM by Auth State

Synthetic runs prove a change is safe; field data proves your baselines still reflect reality. Tag every beacon with the session state at collection time so your P75 and P99 aggregation pipeline can compute percentiles per population instead of blending them. The tag must come from a signal the client already knows without an extra request — a boolean set during hydration, or the presence of a session marker — so measurement adds no weight of its own.

// rum-auth-tag.js — attach session state to every Web Vitals beacon
function sessionState() {
  // set by the server-rendered shell; no network call, no cookie parsing cost
  return window.__APP_STATE__?.authenticated ? 'logged-in' : 'anonymous';
}

function report(metric) {
  const body = JSON.stringify({
    name: metric.name,          // 'LCP' | 'INP' | 'CLS'
    value: Math.round(metric.value),
    profile: sessionState(),
    route: location.pathname,
    ts: Date.now(),
  });
  navigator.sendBeacon('/rum', body);
}

import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(report);
onINP(report);
onCLS(report);

Downstream, the percentile query groups by that profile column. Because authenticated sessions are usually a smaller slice of traffic, keep an eye on sample counts before you trust a logged-in percentile, and lean on the same RUM sampling strategies that keep beacon volume sane without starving the smaller population of samples.

RUM segmentation by session state Tagged beacons split into logged-in and anonymous buckets so field percentiles are computed per population. beacon profile=auth beacon profile=anon /rum collector group by profile Logged-in P75 4600 ms LCP Anonymous P75 3500 ms LCP
A single collector groups tagged beacons into two percentile buckets, so field data validates each baseline against its own population.

Verifying the Split

Prove the segmentation works before you rely on it. First, confirm the authenticated run really is authenticated: inspect the Lighthouse trace for a personalized element — the account name, the cart count — and fail the job if it is absent, because a silently logged-out run would poison the logged-in baseline with anonymous numbers. Second, diff the two profiles and assert the expected gap exists; if the authenticated LCP P75 is not meaningfully higher than the anonymous one on mid-range mobile at Fast 3G, either the login failed or personalization is not rendering. Third, cross-check synthetic against field: the CI medians for each profile should land within the tolerance band of the matching RUM percentile. When the numbers agree across synthetic and field for both populations, the split is trustworthy, and you can extend the same pattern to other audience cuts described in per-template baselines for CMS sites.

Frequently Asked Questions

Should logged-in and anonymous run in the same CI job or separate ones?

Run them as a matrix in one workflow so a single pull request produces both results, but keep the gate comparisons independent. Each profile is checked against its own baseline, and either can fail the check on its own. Sharing the workflow keeps the login secret and Chrome setup in one place while preserving two distinct pass/fail signals.

How do I keep the performance-test account credentials secure?

Store them as CI secrets, never in the repository, and scope the account to a low-privilege throwaway user on staging. Log in through the API rather than the UI so the flow is deterministic, and rotate the password on a schedule. The account should still carry realistic personalization data so the authenticated view is representative.

What if most of my traffic is anonymous — do I still need a logged-in baseline?

Yes, because the authenticated view is where regressions hide when the average is dominated by light anonymous traffic. Even a small logged-in population is your highest-intent users, and a pooled budget lets their experience decay unnoticed. Keep both baselines; just watch the logged-in sample count so a thin population does not produce a jumpy percentile.

Why not just measure the anonymous view since it is faster and simpler?

Because the anonymous number tells you nothing about the personalized shell that signed-in members actually load. Personalization, private (non-CDN) responses, and session-only scripts can add a second or more to LCP P75 on mid-range mobile at Fast 3G. Measuring only the anonymous view leaves that entire experience ungated.