Extend k6 with xk6 custom extensions for browser testing and gRPC, configure Grafana Cloud k6 for distributed cloud test execution, and analyze results in the Grafana web interface.
Local k6 tests are powerful but limited: a single machine can drive roughly 2,000-5,000 VUs before CPU becomes the bottleneck, not the server under test. Grafana Cloud k6 eliminates this limit by distributing load across multiple geographic regions from managed infrastructure. Additionally, k6's extension system (xk6) allows you to add capabilities beyond HTTP: browser automation, gRPC, Kafka, WebSocket, and database testing.
This lesson covers xk6-browser for frontend performance testing alongside API tests, gRPC load testing, and Grafana Cloud k6 for distributed high-scale testing.
xk6-browser (now bundled in k6 v0.46+) runs real Chromium browsers via the Playwright API inside k6 scenarios. This lets you measure Core Web Vitals (LCP, CLS, INP) under load, not just API latency.
1import { browser } from 'k6/experimental/browser';
2import { check } from 'k6';
3
4export const options = {
5 scenarios: {
6 // API load in parallel with browser test
7 api_load: {
8 executor: 'constant-vus',
9 vus: 50,
10 duration: '3m',
11 exec: 'apiTest',
12 },
13 // Browser test — measures real user experience under load
14 browser_check: {
15 executor: 'constant-vus',
16 vus: 2, // browsers are heavy — 2 VUs is enough
17 duration: '3m',
18 exec: 'browserTest',
19 options: { browser: { type: 'chromium' } },
20 },
21 },
22};
23
24export async function browserTest() {
25 const page = await browser.newPage();
26 try {
27 // Navigate to the page under load
28 await page.goto('https://app.example.com/courses');
29
30 // Measure Core Web Vitals using Performance API
31 const lcp = await page.evaluate(() => {
32 return new Promise((resolve) => {
33 new PerformanceObserver((list) => {
34 const entries = list.getEntries();
35 resolve(entries[entries.length - 1].startTime);
36 }).observe({ entryTypes: ['largest-contentful-paint'] });
37 setTimeout(() => resolve(null), 5000);
38 });
39 });
40
41 check(page, {
42 'title is correct': (p) => p.title() === 'Courses | DeveloperBootstrap',
43 });
44
45 check(null, {
46 'LCP under 2500ms': () => lcp !== null && lcp < 2500,
47 });
48
49 // Click and measure interaction time
50 const start = Date.now();
51 await page.locator('a[href*="/courses/"]').first().click();
52 await page.waitForNavigation();
53 const navigationTime = Date.now() - start;
54
55 check(null, {
56 'navigation under 1s': () => navigationTime < 1000,
57 });
58 } finally {
59 await page.close();
60 }
61}
62
63export function apiTest() {
64 http.get('https://api.example.com/courses');
65 sleep(1);
66}k6 has built-in gRPC support for load testing gRPC services. You need the .proto definition file.
1import grpc from 'k6/net/grpc';
2import { check, sleep } from 'k6';
3
4const client = new grpc.Client();
5// Load proto definition — must be in the test data directory
6client.load(['protos'], 'course.proto');
7
8export const options = {
9 vus: 50,
10 duration: '5m',
11 thresholds: {
12 grpc_req_duration: ['p(95)<200'],
13 },
14};
15
16export function setup() {
17 client.connect('grpc.api.example.com:443', { tls: true });
18}
19
20export default function () {
21 const response = client.invoke('course.CourseService/ListCourses', {
22 page: 1,
23 limit: 20,
24 category: 'typescript',
25 });
26
27 check(response, {
28 'gRPC status OK': (r) => r.status === grpc.StatusOK,
29 'has courses': (r) => r.message.courses.length > 0,
30 });
31
32 sleep(0.5);
33}
34
35export function teardown() {
36 client.close();
37}The grpc_req_duration metric works like http_req_duration and accepts the same threshold syntax. Use check(response.status === grpc.StatusOK) to verify correct status codes (OK, NOT_FOUND, PERMISSION_DENIED, etc.).
Grafana Cloud k6 distributes load across multiple regions and removes local machine limits. Up to 50 VUs are free tier.
1# Install k6 and authenticate
2brew install k6
3k6 login cloud --token your_api_token # from app.k6.io → API Token
4
5# Run a cloud test from local script
6k6 cloud tests/perf/load.js
7
8# Run with specific cloud options
9k6 cloud --env BASE_URL=https://staging.api.example.com tests/perf/load.jsIn your k6 script, add cloud options:
1export const options = {
2 cloud: {
3 projectID: 3643870, // from Grafana Cloud k6 project settings
4 name: 'API Load Test v2.4.1', // human-readable name
5 distribution: {
6 ashburn: { loadZone: 'amazon:us:ashburn', percent: 50 },
7 dublin: { loadZone: 'amazon:eu:dublin', percent: 30 },
8 sydney: { loadZone: 'amazon:au:sydney', percent: 20 },
9 },
10 },
11 scenarios: {
12 load: {
13 executor: 'ramping-vus',
14 stages: [
15 { duration: '5m', target: 200 }, // 200 VUs across 3 regions
16 { duration: '15m', target: 200 },
17 { duration: '5m', target: 0 },
18 ],
19 },
20 },
21};Grafana Cloud shows results broken down by load zone — useful for identifying geographic performance differences (e.g., users in Sydney see 800ms p95 while Ashburn users see 200ms p95, revealing CDN misconfiguration).
Integrate Grafana Cloud k6 into GitHub Actions to run cloud tests on every deploy:
1# .github/workflows/perf.yml
2name: Performance Tests
3
4on:
5 push:
6 branches: [main]
7 workflow_dispatch:
8 inputs:
9 environment:
10 description: 'Target environment'
11 default: 'staging'
12
13jobs:
14 performance:
15 runs-on: ubuntu-latest
16 steps:
17 - uses: actions/checkout@v4
18
19 - name: Install k6
20 run: |
21 sudo gpg -k
22 sudo gpg --no-default-keyring \
23 --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
24 --keyserver hkp://keyserver.ubuntu.com:80 \
25 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
26 echo 'deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main' \
27 | sudo tee /etc/apt/sources.list.d/k6.list
28 sudo apt-get update && sudo apt-get install k6
29
30 - name: Run cloud performance test
31 env:
32 K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }}
33 BASE_URL: ${{ vars.STAGING_API_URL }}
34 run: |
35 k6 cloud \
36 --env BASE_URL=$BASE_URL \
37 tests/perf/load.js
38 # k6 exits 99 if thresholds breach — GitHub Actions marks step failed
39
40 - name: Upload results
41 if: always()
42 uses: actions/upload-artifact@v4
43 with:
44 name: k6-results
45 path: tests/perf/results/The Grafana Cloud k6 web UI stores all historical test runs, enabling you to compare performance across deployments and catch regressions that only appear over time.
Grafana Cloud k6 generates richer reports than local runs. Key sections:
Performance Overview: Time-series chart of RPS, VUs, and p95 latency across the full test duration. Look for the "knee" where latency starts rising faster than RPS.
HTTP Endpoints Table: Every unique request name (set via tags) listed with p50, p95, p99, error rate, and request count. Sort by p95 descending to find the slowest endpoints.
Geographic Breakdown: Latency and error rate per load zone. Asymmetric results reveal CDN or routing issues.
Threshold Results: A traffic-light table showing which SLOs passed (green) and failed (red). Failed thresholds link to the time period of the failure.
Logs: Custom console.log output from your script — use for debugging specific iteration failures.
1// Add structured logging for cloud test debugging
2export default function () {
3 const res = http.get('https://api.example.com/courses');
4
5 if (res.status >= 500) {
6 // This appears in Grafana Cloud k6 Logs tab
7 console.error(JSON.stringify({
8 vu: __VU,
9 iter: __ITER,
10 status: res.status,
11 body: res.body?.substring(0, 200), // truncate large responses
12 duration: res.timings.duration,
13 }));
14 }
15}A k6 script running browser-based Core Web Vitals measurement in parallel with API load testing, demonstrating the full xk6-browser API.
1// tests/perf/browser-api-combined.js
2// Requires: k6 v0.46+ (xk6-browser bundled)
3// Run: k6 run --env BASE_URL=http://localhost:3000 tests/perf/browser-api-combined.js
4
5import { browser } from 'k6/experimental/browser';
6import http from 'k6/http';
7import { check, sleep } from 'k6';
8import { Trend } from 'k6/metrics';
9
10const lcpMetric = new Trend('lcp_milliseconds', true);
11const fidMetric = new Trend('fid_milliseconds', true);
12
13export const options = {
14 scenarios: {
15 api_load: {
16 executor: 'ramping-vus',
17 vus: 0,
18 stages: [
19 { duration: '2m', target: 50 },
20 { duration: '5m', target: 50 },
21 { duration: '1m', target: 0 },
22 ],
23 exec: 'apiLoad',
24 tags: { scenario: 'api' },
25 },
26 browser_vitals: {
27 executor: 'constant-vus',
28 vus: 1,
29 duration: '8m',
30 exec: 'browserVitals',
31 options: { browser: { type: 'chromium', headless: true } },
32 tags: { scenario: 'browser' },
33 startTime: '0s',
34 },
35 },
36 thresholds: {
37 'http_req_duration{scenario:api}': ['p(95)<800'],
38 'http_req_failed{scenario:api}': ['rate<0.005'],
39 lcp_milliseconds: ['p(75)<2500'], // Core Web Vitals: LCP Good <2.5s
40 },
41};
42
43const BASE = __ENV.BASE_URL || 'http://localhost:3000';
44
45export function apiLoad() {
46 const res = http.get(`${BASE}/api/courses?limit=12`,
47 { tags: { name: 'courses-api' } });
48 check(res, { 'courses 200': (r) => r.status === 200 });
49 sleep(0.5 + Math.random());
50}
51
52export async function browserVitals() {
53 const page = await browser.newPage();
54 page.setDefaultTimeout(15000);
55
56 try {
57 const startNav = Date.now();
58 await page.goto(`${BASE}/courses`, { waitUntil: 'networkidle' });
59 const navTime = Date.now() - startNav;
60
61 // Collect LCP via PerformanceObserver
62 const lcp = await page.evaluate(() =>
63 new Promise((resolve) => {
64 let lcpValue = null;
65 const observer = new PerformanceObserver((list) => {
66 const entries = list.getEntries();
67 lcpValue = entries[entries.length - 1]?.startTime;
68 });
69 observer.observe({ type: 'largest-contentful-paint', buffered: true });
70 setTimeout(() => resolve(lcpValue), 3000);
71 })
72 );
73
74 if (lcp) lcpMetric.add(lcp);
75
76 check(page, {
77 'page title present': async (p) => (await p.title()).length > 0,
78 'courses heading visible': async () =>
79 page.locator('h1').isVisible(),
80 });
81
82 check(null, {
83 'page load under 3s': () => navTime < 3000,
84 'LCP under 2.5s': () => lcp !== null && lcp < 2500,
85 });
86
87 // Measure FID proxy: time to click first interactive element
88 const fidStart = Date.now();
89 const firstCard = page.locator('[data-testid="course-card"]').first();
90 if (await firstCard.isVisible()) {
91 await firstCard.click();
92 fidMetric.add(Date.now() - fidStart);
93 }
94
95 } finally {
96 await page.close();
97 }
98
99 sleep(10); // browsers run 1 VU — sleep between full page loads
100}This script runs two scenarios simultaneously: an API load scenario with 50 VUs hammering the courses endpoint for 8 minutes, and a browser scenario with 1 VU measuring actual Core Web Vitals (LCP, FID proxy) during that load. The browser VU gives you the real user experience under load conditions — a p95 LCP of 4 seconds while the API shows p95 200ms indicates a frontend rendering bottleneck, not a backend problem. Custom Trend metrics (lcp_milliseconds, fid_milliseconds) appear in k6 thresholds and Grafana dashboards.
The next lesson covers Lighthouse CLI and Core Web Vitals measurement — running Lighthouse in CI, interpreting LCP/CLS/INP scores, and setting up performance budgets that block deployments when scores regress.
Your progress is saved in this browser, on this device only.