Resource library

QA How-To

k6 Browser Web Vitals Testing Tutorial: Measure Core Web Vitals (2026)

Follow this k6 browser web vitals testing tutorial to measure LCP, INP, CLS, FCP, and TTFB, enforce budgets, and catch frontend regressions in CI pipelines.

18 min read | 2,816 words

TL;DR

Use `k6/browser`, declare a Chromium browser scenario, visit the page, perform a meaningful interaction, and close the page in `finally`. k6 emits LCP, INP, CLS, FCP, and TTFB automatically; thresholds convert those measurements into release gates.

Key Takeaways

  • Configure a browser scenario with Chromium instead of running browser code in a protocol-only scenario.
  • Use k6 built-in metrics for LCP, INP, CLS, FCP, and TTFB without adding a Web Vitals library.
  • Trigger a realistic interaction so the test produces a useful INP sample.
  • Turn performance budgets into thresholds that return a nonzero process exit code.
  • Filter thresholds by exact URL when one journey visits pages with different budgets.
  • Run several controlled iterations and compare percentiles instead of trusting one local observation.
  • Keep browser checks small and combine them with protocol load when you need high traffic volume.

A k6 browser web vitals testing tutorial should leave you with more than a page-load script. You need repeatable measurements, realistic interaction, explicit performance budgets, and a command that fails when the experience regresses. k6 Browser provides all four through its Chromium-based browser module and standard threshold engine.

This tutorial builds that workflow from an empty directory. It belongs to the k6 Performance Engineering Complete Guide, which explains how browser, protocol, distributed, streaming, and observability tests fit into one performance strategy.

You will measure Largest Contentful Paint (LCP), Interaction to Next Paint (INP), Cumulative Layout Shift (CLS), First Contentful Paint (FCP), and Time to First Byte (TTFB). You will then add URL-specific budgets, a custom user-flow metric, repeatable execution, and a CI gate.

What You Will Build

By the end, you will have:

  • A runnable k6 Browser script that opens a real Chromium page.
  • A user journey that validates visible content and creates an interaction for INP.
  • Built-in Web Vital thresholds that make the run pass or fail.
  • A custom Trend measuring a business action from click to visible result.
  • A multi-iteration test that produces more useful percentile data.
  • A GitHub Actions workflow that blocks a change when a budget fails.

The example targets https://test.k6.io/browser.php, a public k6 demonstration page. Replace BASE_URL with an environment variable when testing your own application. Do not aim heavy or frequent traffic at a public example site.

Prerequisites

Use the latest stable k6 release available to your team in 2026 and a Chromium-based browser such as Google Chrome or Chromium. Confirm both executables before creating the test.

k6 version

# macOS
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version

# Linux, depending on the installed package
google-chrome --version || chromium --version

Install k6 with the method maintained for your operating system. On macOS with Homebrew:

brew install k6

On Debian or Ubuntu, use the official k6 package repository rather than assuming the distribution repository is current. In CI, pin the runner or container version your team has validated. You also need a terminal, permission to reach the system under test, and a text editor.

Create an isolated project directory:

mkdir k6-web-vitals
cd k6-web-vitals

Verify: k6 version prints a version and exits successfully. A Chrome or Chromium version command also prints a version. If k6 cannot find the browser later, set K6_BROWSER_EXECUTABLE_PATH to the executable's absolute path.

Step 1: Create the Smallest k6 Browser Test

Create web-vitals.js with a browser-enabled scenario. Browser execution is configured inside the scenario's options.browser object. The supported browser type is chromium.

import { browser } from 'k6/browser';
import { check } from 'k6';

export const options = {
  scenarios: {
    web_vitals: {
      executor: 'shared-iterations',
      vus: 1,
      iterations: 1,
      options: {
        browser: {
          type: 'chromium',
        },
      },
    },
  },
  thresholds: {
    checks: ['rate==1'],
  },
};

export default async function () {
  const page = await browser.newPage();

  try {
    const response = await page.goto('https://test.k6.io/browser.php', {
      waitUntil: 'networkidle',
    });

    check(response, {
      'document response is successful': (r) => r !== null && r.status() === 200,
    });
  } finally {
    await page.close();
  }
}

browser.newPage() creates a page in a new browser context. The try/finally block matters because a failed navigation or assertion must not leak the page. networkidle is useful for this stable example, but it is not universally correct. Applications with polling, analytics, or persistent connections may never become idle. For those applications, use the default navigation lifecycle and wait for a user-visible locator instead.

Run it:

k6 run web-vitals.js

Verify: the summary contains checks plus browser metrics whose names begin with browser_. The process exits with code 0, and the successful document check reports 100 percent.

Step 2: Read the Built-in Web Vitals

No third-party Web Vitals package is required. A browser scenario automatically emits the measurements in the following table. k6 stores timing metrics as Trend values, while CLS is a unitless score.

k6 metric What it represents Diagnostic question
browser_web_vital_lcp Largest Contentful Paint When does the main content appear?
browser_web_vital_inp Interaction to Next Paint How quickly does the page respond to an interaction?
browser_web_vital_cls Cumulative Layout Shift How much does visible content move unexpectedly?
browser_web_vital_fcp First Contentful Paint When does the first content render?
browser_web_vital_ttfb Time to First Byte How long does initial server response take?
browser_http_req_duration Browser resource request duration Are document or asset requests slow?
browser_http_req_failed Failed browser resource requests Did any browser request fail?

Run the first script again and locate these rows in the end-of-test summary. A single iteration can teach you the output shape, but it is not a performance baseline. Local CPU load, browser startup, network conditions, caches, and application state all introduce variation. Treat the value as a sample, not a universal statement about users.

Also distinguish lab and field data. k6 Browser produces controlled lab measurements from the environment where you execute it. Real User Monitoring aggregates field experiences across actual devices, locations, networks, and sessions. Use a k6 release gate for reproducible regression detection, then compare it with field telemetry to understand production impact.

Verify: the summary includes LCP, CLS, FCP, and TTFB. INP may be missing or unhelpful when the page receives no qualifying interaction. The next step deliberately creates one.

Step 3: Add a Real User Interaction for INP

Loading a page is not enough to test interactivity. Update the default function so the browser fills a form, clicks submit, and waits for the result. Use role and label locators because they describe what a user perceives.

export default async function () {
  const page = await browser.newPage();

  try {
    const response = await page.goto('https://test.k6.io/browser.php');

    check(response, {
      'document response is successful': (r) => r !== null && r.status() === 200,
    });

    await page.getByLabel('First name:').fill('Ada');
    await page.getByLabel('Last name:').fill('Lovelace');
    await page.getByRole('button', { name: 'Submit' }).click();

    const greeting = page.locator('p#result');
    await greeting.waitFor({ state: 'visible' });

    check(await greeting.textContent(), {
      'result contains submitted name': (text) =>
        text !== null && text.includes('Ada') && text.includes('Lovelace'),
    });
  } finally {
    await page.close();
  }
}

Keep the options object and imports from Step 1. Only replace the default function. The click supplies an interaction that the browser can observe for INP. The visible result also prevents a false pass where the test clicks an element but never confirms the application response.

Do not add an arbitrary sleep after the click. Waiting on the actual result is faster when the application is healthy and exposes a meaningful timeout when it is not. If your application uses a canvas or inaccessible controls, prefer a stable application-owned locator over brittle coordinate clicks.

Verify: both checks pass. The summary now includes browser_web_vital_inp when Chromium records the interaction, and the result check proves the action completed. Run headfully with K6_BROWSER_HEADLESS=false k6 run web-vitals.js if you need to watch locator behavior locally.

Step 4: Set Budgets in This k6 browser web vitals testing tutorial

Measurements become release controls only after you define thresholds. Add the following thresholds to the existing options. These are example lab budgets, not claims about every product. Agree on budgets using your user expectations, baseline, device profile, and risk tolerance.

export const options = {
  scenarios: {
    web_vitals: {
      executor: 'shared-iterations',
      vus: 1,
      iterations: 5,
      maxDuration: '2m',
      options: {
        browser: { type: 'chromium' },
      },
    },
  },
  thresholds: {
    checks: ['rate==1'],
    browser_web_vital_lcp: ['p(90)<2500'],
    browser_web_vital_inp: ['p(90)<200'],
    browser_web_vital_cls: ['p(90)<0.1'],
    browser_web_vital_fcp: ['p(90)<1800'],
    browser_web_vital_ttfb: ['p(90)<800'],
    browser_http_req_failed: ['rate<0.01'],
  },
};

Duration values for the browser timing Trends are expressed in milliseconds inside threshold expressions. CLS remains unitless. The p(90) expression asks k6 to compare the 90th percentile of collected samples with the budget. Five iterations are enough for a quick demonstration, but far too few for a stable percentile claim. In a real pipeline, select a sample count that balances signal, duration, infrastructure cost, and environment capacity.

Run the test and explicitly inspect its status:

k6 run web-vitals.js
echo $?

Verify: each threshold has a checkmark when it passes. If any threshold fails, k6 exits nonzero. Prove the gate works by temporarily setting LCP to an intentionally tiny value such as p(90)<1; the run should fail. Restore the agreed budget afterward.

Step 5: Apply URL-specific Thresholds

A journey may visit pages with different performance expectations. k6 browser metrics include a url tag, so you can filter a threshold with exact URL syntax. This is more precise than forcing a checkout page and a documentation page to share one budget.

const BASE_URL = __ENV.BASE_URL || 'https://test.k6.io';

export const options = {
  scenarios: {
    web_vitals: {
      executor: 'shared-iterations',
      vus: 1,
      iterations: 3,
      options: { browser: { type: 'chromium' } },
    },
  },
  thresholds: {
    checks: ['rate==1'],
    [`browser_web_vital_lcp{url:${BASE_URL}/browser.php}`]: ['p(90)<2500'],
    [`browser_web_vital_cls{url:${BASE_URL}/browser.php}`]: ['p(90)<0.1'],
    [`browser_web_vital_inp{url:${BASE_URL}/browser.php}`]: ['p(90)<200'],
  },
};

Update navigation to ${BASE_URL}/browser.php, then run against the default or your environment:

k6 run web-vitals.js
BASE_URL=https://your-test-environment.example k6 run web-vitals.js

The URL filter must match the metric tag exactly, including scheme, host, path, and relevant trailing slash. Redirects can change the final URL and leave the filtered submetric without samples. Inspect the summary or exported metrics when a threshold unexpectedly reports no data.

Avoid putting secrets in BASE_URL. Pass authentication through an approved test-account mechanism and keep credentials in the CI secret store. Keep the target host allowlisted so a typo cannot point browser traffic at production.

Verify: the summary displays indented URL-tagged submetrics beneath each Web Vital. The threshold evaluates samples for the intended final URL, not all navigation events in the scenario.

Step 6: Measure a Custom Business Interaction

Web Vitals do not replace journey-specific timing. A search, quote calculation, or dashboard refresh can be important even when page-level LCP is healthy. Use the browser Performance API inside page.evaluate, then add the resulting duration to a k6 Trend.

import { browser } from 'k6/browser';
import { check } from 'k6';
import { Trend } from 'k6/metrics';

const formResultDuration = new Trend('form_result_duration', true);

export const options = {
  scenarios: {
    web_vitals: {
      executor: 'shared-iterations',
      vus: 1,
      iterations: 5,
      options: { browser: { type: 'chromium' } },
    },
  },
  thresholds: {
    checks: ['rate==1'],
    browser_web_vital_lcp: ['p(90)<2500'],
    browser_web_vital_inp: ['p(90)<200'],
    browser_web_vital_cls: ['p(90)<0.1'],
    form_result_duration: ['p(90)<1000'],
  },
};

export default async function () {
  const page = await browser.newPage();

  try {
    await page.goto('https://test.k6.io/browser.php');
    await page.getByLabel('First name:').fill('Ada');
    await page.getByLabel('Last name:').fill('Lovelace');

    await page.evaluate(() => performance.mark('form-submit-start'));
    await page.getByRole('button', { name: 'Submit' }).click();

    const result = page.locator('p#result');
    await result.waitFor({ state: 'visible' });
    await page.evaluate(() => performance.mark('form-result-visible'));

    const duration = await page.evaluate(() => {
      performance.measure(
        'form-result',
        'form-submit-start',
        'form-result-visible',
      );
      return performance.getEntriesByName('form-result').at(-1).duration;
    });

    formResultDuration.add(duration);
    check(duration, {
      'custom duration is positive': (value) => value > 0,
    });
  } finally {
    await page.close();
  }
}

The boolean passed to Trend marks values as time, so summaries format them as durations. The measurement begins immediately before the action and ends only after the user-visible outcome appears. This boundary is more meaningful than timing the click promise alone.

Verify: the summary contains form_result_duration, its count corresponds to completed iterations, and its threshold passes or fails with the process exit code.

Step 7: Make the Test Repeatable

Browser results are sensitive to environment drift. Control what you can: runner size, browser and k6 versions, test data, target deployment, network location, and background workloads. Do not compare a developer laptop on Wi-Fi directly with a dedicated CI runner and call the difference a product regression.

Use a modest browser scenario for frontend evidence. Chromium virtual users consume substantially more resources than protocol-level virtual users. For large traffic, drive most load through k6 HTTP scenarios and run a small browser scenario alongside it to observe experience under load.

Add a simple execution wrapper to standardize target selection and preserve a machine-readable summary:

mkdir -p results
BASE_URL=https://your-test-environment.example \
  k6 run --summary-export=results/web-vitals-summary.json web-vitals.js

The summary export is useful for retaining a build artifact, but do not build a long-term time series by parsing pretty terminal text. Use a supported k6 output for durable trend analysis. Compare like with like, and review distributions rather than only averages. Averages can hide a slow tail that users notice.

For a robust test plan, document cache state. A fresh browser context creates a cold session per iteration in this design. If you need warm-session behavior, model it deliberately rather than accidentally reusing state. Seed test accounts before execution and clean them through an idempotent API or fixture process.

Verify: results/web-vitals-summary.json exists after the run and contains the threshold outcome. Repeating the command uses the same script, target variable, scenario, and budgets.

Step 8: Add a CI Performance Gate

Commit the script, then create .github/workflows/web-vitals.yml. Pin a k6 version your team has tested instead of silently changing the tool on every build. The example uses the official setup action and installs Chrome through the maintained browser setup action.

name: Web Vitals

on:
  pull_request:
  workflow_dispatch:

jobs:
  k6-browser:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4

      - uses: browser-actions/setup-chrome@v1

      - uses: grafana/setup-k6-action@v1
        with:
          k6-version: 'latest' # Replace with your validated pinned version.

      - name: Run Web Vitals budgets
        env:
          BASE_URL: ${{ vars.PERFORMANCE_BASE_URL }}
        run: k6 run --summary-export=web-vitals-summary.json web-vitals.js

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: web-vitals-summary
          path: web-vitals-summary.json

Replace latest with a validated numeric version before relying on this workflow as a stable gate. Store the non-secret target as a repository variable. Use environment protection and secrets for authenticated targets. Ensure the target is deployed and healthy before the performance job begins, or the test will correctly fail for availability rather than a frontend regression.

Start with an informative scheduled or manual run if the environment is noisy. Promote it to a pull-request requirement after the team trusts the signal, owns failures, and has a documented rerun policy. Never loosen a budget automatically just to make the pipeline green.

Verify: trigger the workflow manually. The job uploads the JSON summary even on failure because if: always() is set. A failed k6 threshold makes the test step and job fail.

Troubleshooting

Problem: k6 cannot find Chrome or Chromium -> Install a Chromium-based browser and set K6_BROWSER_EXECUTABLE_PATH to its absolute executable path. Confirm the CI user can execute it.

Problem: navigation waits forever with networkidle -> Remove waitUntil: 'networkidle' for applications with polling, streaming, or persistent connections. Wait for a stable visible locator that represents readiness. For streaming performance behavior, use the k6 Server-Sent Events testing tutorial.

Problem: INP is absent or has no useful samples -> Perform a real click, keypress, or tap and wait for its rendered result. A navigation-only script cannot evaluate interaction responsiveness well. Confirm that the interaction happens before the page closes.

Problem: a URL-filtered threshold has no samples -> Match the final URL exactly. Check scheme, port, query, redirect destination, and trailing slash. Start with an unfiltered metric, inspect tags through your chosen output, then restore the filter.

Problem: results vary dramatically between runs -> Stabilize runner resources, browser version, target build, data, geography, cache state, and competing workloads. Increase controlled repetitions, then compare percentiles without claiming that a tiny sample represents production users.

Problem: browser virtual users exhaust CPU or memory -> Reduce browser VUs. Generate high concurrency with protocol-level scenarios and reserve a small number of browser users for experience measurement. Distribute deliberate high-scale execution using the k6 Kubernetes Operator load testing guide.

Best Practices and Common Mistakes

Do test a user-visible outcome after every important interaction. A fast click that produces the wrong result is not a performance success. Keep functional checks and performance thresholds together so availability, correctness, and speed are visible in one run.

Do version your budgets with the test. Review them when product behavior changes, but preserve historical reasoning in the pull request. Segment pages by exact URL when their content and risk differ. Keep browser concurrency low enough that the load generator does not become the bottleneck.

Do not treat one run as a benchmark or compare unlike environments. Do not use sleeps to hide synchronization problems. Do not set thresholds from generic numbers alone without measuring your baseline and user needs. Do not put production credentials in a script, and do not send browser load to a host you do not own or have permission to test.

Correlate a failed experience metric with backend evidence. A high TTFB may originate in the application or network, while a healthy TTFB paired with poor LCP can point toward render-blocking assets, image delivery, or client work. Exporting telemetry with the k6 OpenTelemetry traces tutorial helps connect a browser symptom to downstream services.

Where To Go Next

Return to the complete k6 performance engineering guide to design the wider workload, environment, observability, and reporting strategy around this browser gate.

Then deepen the parts your system needs:

Your immediate next step is simple: replace the example URL with a non-production environment you control, choose one revenue-critical or task-critical interaction, record a baseline under controlled conditions, and agree on the first budget with the team.

Interview Questions and Answers

Q: What does k6 Browser measure automatically?

A browser-enabled scenario emits browser HTTP metrics and Web Vitals including LCP, INP, CLS, FCP, and TTFB. The test must exercise the relevant lifecycle. For example, it needs a meaningful interaction to produce useful INP evidence.

Q: Why should a k6 browser scenario use fewer VUs than an HTTP scenario?

Each browser VU controls a real Chromium process and consumes much more CPU and memory than a protocol VU. Use protocol tests for high traffic volume, then use a small browser population to observe frontend experience under that load.

Q: How do thresholds make Web Vitals actionable?

Thresholds compare aggregated metrics with explicit budgets. If a condition fails, k6 returns a nonzero exit code, which lets CI block a release. The team should base budgets on controlled baselines, user expectations, and risk.

Q: Why use a percentile instead of an average?

An average can hide slow samples behind many fast ones. A percentile expresses the boundary below which a chosen share of samples falls. It is still only meaningful when the sample, environment, and test design are credible.

Q: What is the difference between lab and field Web Vitals?

k6 Browser measures a controlled synthetic journey in a lab-like runner. Field monitoring captures real users across varied devices, networks, and behavior. Use synthetic tests for repeatable pre-release gates and field data for production reality.

Q: How would you investigate high LCP with normal TTFB?

First confirm the test environment and sample quality. Then inspect the LCP element, image size and priority, render-blocking resources, CSS, fonts, and client rendering work. Correlate browser evidence with resource timings and traces before assigning the cause.

Conclusion

This k6 browser web vitals testing tutorial gives you a complete path from navigation to enforcement. Configure Chromium in a browser scenario, validate the journey, trigger a meaningful interaction, read built-in metrics, and convert agreed budgets into thresholds.

Keep the gate controlled and modest. Combine browser evidence with protocol load and production field telemetry, preserve the result as an artifact, and investigate failures rather than normalizing them. That turns Web Vitals from dashboard decoration into an engineering feedback loop.

Interview Questions and Answers

Which Web Vitals can k6 Browser report?

k6 Browser reports built-in LCP, INP, and CLS metrics, plus FCP and TTFB. It also reports browser request duration and failures. The script must create the lifecycle event being measured, especially a real interaction for useful INP data.

How do you configure browser execution in k6?

Define a scenario and place `{ browser: { type: 'chromium' } }` inside that scenario's options. Import `browser` from `k6/browser`, create a page in the default async function, and close it in a finally block.

How do k6 thresholds work as a CI gate?

A threshold evaluates an aggregation such as `p(90)` or `rate` against a limit. Failed thresholds make k6 exit nonzero, so a normal CI runner marks the step failed. Budgets should be reviewed and versioned with the test.

Why combine protocol and browser tests?

Protocol virtual users generate large server-side traffic efficiently, while browser users reveal rendering and interaction experience. Combining them lets a small set of real browsers observe the frontend while protocol scenarios create the intended backend load.

How would you reduce noisy Web Vital results?

Control runner capacity, k6 and browser versions, target build, test data, network location, cache state, and background workloads. Collect repeated samples and compare percentiles only across equivalent environments. Also confirm that the load generator is not saturated.

When would you create a custom Trend in a browser test?

Create one when the business journey has an important boundary that a page-level Web Vital does not represent, such as click-to-search-results or submit-to-confirmation. Mark the start and end in the browser Performance API, return the duration, add it to a Trend, and enforce a threshold.

Frequently Asked Questions

Can k6 measure Core Web Vitals?

Yes. A k6 browser scenario emits built-in metrics for LCP, INP, and CLS, along with FCP and TTFB. Your script must navigate a real page, and it should perform a meaningful interaction when you need INP evidence.

Does k6 Browser require Chrome?

k6 Browser requires a Chromium-based browser. Install Google Chrome or Chromium, and set K6_BROWSER_EXECUTABLE_PATH if k6 cannot discover the executable automatically.

How do I fail a k6 test when LCP is too slow?

Add a threshold such as `browser_web_vital_lcp: ['p(90)<2500']` to the options object. If the collected percentile violates that illustrative budget, k6 exits nonzero and the CI step fails.

Why is INP missing from my k6 output?

A page-load-only script may not create a qualifying interaction. Click, type, or tap as a user would, wait for the rendered response, and keep the page open long enough for Chromium to report the measurement.

Should I run hundreds of k6 browser VUs?

Usually not. Real browser VUs are resource intensive. Generate large traffic volumes with protocol-level k6 scenarios and use a small browser scenario to measure frontend experience under load.

Can k6 apply different Web Vital budgets to different pages?

Yes. Filter a browser metric threshold with its exact URL tag, for example `browser_web_vital_lcp{url:https://example.test/checkout}`. Account for redirects and trailing slashes because the tag must match the observed URL.

Are k6 Web Vitals the same as real user data?

No. k6 produces synthetic lab measurements from the configured runner and journey. Real User Monitoring reflects varied production users, so the two sources complement each other rather than replace each other.

Related Guides