k6 Only Reports LCP for the Last Page You Close

The synthetic checks were live, the metrics were arriving, and the dashboard looked wrong in a way that took a while to name. Time to first byte was there for everything. Largest Contentful Paint was there for almost nothing.

Sixty-one URLs reporting TTFB. Nine reporting LCP.

If that had been a random 15 percent, it would have read as flakiness — slow pages, timeouts, a probe having a bad day — and the reasonable next move would have been to raise a timeout and look again tomorrow. It wasn’t random. The nine were exactly the last URL of each check script.

TL;DR: k6’s browser module finalises Largest Contentful Paint when the page closes. Walking several URLs through one reused page object means only the final navigation ever reports LCP; every earlier one is discarded when the next goto() replaces it. The fix is a fresh page per target — and newPage() opens a browser context as well, so closing the page alone makes the next iteration fail. The distribution of the missing data is what identified the mechanism.

Why Was a Single Script Visiting Nine Pages?

Because the vendor caps browser checks at ten per tenant, and the estate has about twenty sites at sixty-one monitored URLs. The Terraform that generates these checks therefore groups several sites into one check, which is also the cheaper arrangement: billing counts started minutes per probe location, not pages, so a script walking nine URLs costs the same as one loading three.

So each check is a k6 script with a target list, and the obvious implementation is one page, several navigations:

// The version that loses LCP
export default async function () {
  const page = await browser.newPage();
  for (const target of TARGETS) {
    await page.goto(target.url, { waitUntil: 'load' });
  }
  await page.close();
}

That runs. It reports HTTP status for every URL. It reports TTFB for every URL. It reports LCP once.

Why Does Reusing One Page Lose the Measurement?

Because of what Largest Contentful Paint is. It isn’t a value the page hands you at load time — it’s the largest contentful element painted so far, and "so far" doesn’t end at load. A late-arriving hero image replaces the candidate; a lazy-loaded banner replaces it again. The value is only final when the page stops being able to produce a larger one.

k6 resolves that by finalising the web-vitals measurement when the page closes. It’s a reasonable rule, and combined with a reused page object it means every navigation but the last has its pending LCP thrown away — the next goto() replaces the document before anything has finalised it. No error, no warning. The check passes, the assertion on HTTP 200 passes, TTFB arrives because that one is complete the moment the first byte lands.

This is why the shape of the missing data mattered more than the amount. Metrics missing at random point at the environment: the network, the probe, a timeout. Metrics missing in a pattern that lines up exactly with a position in your own loop point at your own code, and specifically at something that happens once per loop rather than once per iteration.

Concretely, that’s the diagnostic worth keeping: before explaining a gap by flakiness, check whether the surviving records share a position rather than a property. Nine out of sixty-one being slow pages is a monitoring problem. Nine out of sixty-one being the ninth of nine is an architecture problem.

What Does the Fixed Version Look Like?

One page per target, closed before the next one opens:

export default async function () {
  for (const target of TARGETS) {
    const page = DEVICE ? await browser.newPage(DEVICE) : await browser.newPage();
    try {
      const response = await page.goto(target.url, { waitUntil: 'load' });
      check(response, {
        'HTTP 200': (r) => r !== null && r.status() === 200,
      });
    } finally {
      // newPage() opens a page *and* a browser context. Closing only the page
      // leaves the context behind, and the next newPage() fails with
      // "existing browser context must be closed before creating a new one".
      await page.close();
      await browser.closeContext();
    }
  }
}

Two details in there cost more time than the fix itself.

newPage() creates a browser context too. Close only the page and the second iteration fails with existing browser context must be closed before creating a new one. The error is clear once you’ve read it; what isn’t obvious beforehand is that a function named newPage allocates two things and only one of them has an obvious closer.

The cleanup belongs in finally. A page that fails to load — the exact case worth measuring — would otherwise leak its context and take down every remaining target in the same script with an error that has nothing to do with the site that actually broke. That turns one failing URL into a whole group of blind checks, which is the failure mode where monitoring lies to you rather than merely missing something.

Closing per iteration does cost a browser context per page instead of one per script. That’s a real price, and here it’s affordable: a single check measured at about 1.0 s median including browser start, against a billing unit of one started minute. If the group were large enough to push past sixty seconds, the answer would be smaller groups — not a reused page, because a reused page doesn’t measure the thing the whole system exists to measure.

What Else Isn’t in the Documentation Snippet?

The options block. The vendor’s example shows the browser configuration and nothing else, which is not a complete k6 options object, and the failure is not a warning:

// A scenario needs an executor -- without one k6 aborts with "scenario 'ui'
// doesn't have a specified executor type". The Grafana docs snippet shows only
// the browser part and is not a complete options block.
export const options = {
  scenarios: {
    ui: {
      executor: 'shared-iterations',
      vus: 1,
      iterations: 1,
      options: {
        browser: { type: 'chromium' },
      },
    },
  },
};

Worth noting the shape: options.scenarios.<name>.options.browser, with options appearing at two different depths meaning two different things. Copying the browser fragment into the wrong level is the other way to spend an afternoon here.

Device emulation rides on the same newPage() call, which is why the fixed loop takes it as a parameter:

default = {
  viewport          = { width = 412, height = 915 }
  deviceScaleFactor = 2.625
  isMobile          = true
  hasTouch          = true
  userAgent         = "Mozilla/5.0 (Linux; Android 14; Pixel 7) ..."
}

A mid-range Android rather than an iPhone, because the audience for these sites is majority Android — a reference device should match the readers you have, not the phone in the pocket of whoever configured the monitoring. And mobile runs as an additional check rather than a replacement, because the point is comparing the same pages across both, which costs a slot against the cap of ten.

What Does This Not Tell You?

The same thing every synthetic measurement doesn’t tell you: whether real users experience any of it. These checks answer "did it get slower" from a fixed location on a fixed schedule with a fixed device profile. They don’t answer "for whom."

And an LCP number that now arrives for every URL is still only a number. An earlier post in this series covered weeks of hero-image optimization that moved nothing, because the element being measured was a cookie banner’s logo. Getting the metric reported correctly for all sixty-one URLs and knowing which element it refers to are two separate problems, and this fixes the first one.

What’s the Generalizable Lesson Here?

  • When data is missing, look at the shape of what survived before reaching for an environmental explanation. Records that share a position in your loop are a code problem; records that share a property are a data problem; records that share nothing are the only ones that might be flakiness.
  • Metrics with a finalisation step behave differently from metrics that are complete on arrival. TTFB is done when the first byte lands; LCP is done when something declares it done. Any measurement that gets finalised on an event has a lifecycle you have to respect, and reusing the object it’s attached to silently discards it.
  • A creation function that allocates more than its name suggests is a leak waiting to happen. newPage() opens a page and a context; only the page has an obvious closer.
  • Put resource cleanup in finally, especially in monitoring code. The iteration most likely to throw is the one measuring the site that’s actually broken, and without it a single failing URL blinds every check after it in the same run.
  • Documentation snippets show the part being documented, not a working program. A fragment that omits the executor produces an abort, not a default.
  • Choose reference devices from your audience’s data rather than convention. A mid-range Android and a current iPhone are different measurements, and only one of them is about your readers.

Getting the metric emitted is still not the same as getting it read correctly. The dashboard built on top of these checks went red on every panel except one, and the failure this whole setup cannot see on its own turned out to be a page silently dropping out of a script — the next post.