The Independent Blueprint for Modern Load Testing & Grafana k6 Performance Engineering

Hybrid Load Testing: Combining k6 Protocol VUs with k6/browser and Playwright

Posted on May 24, 2026 in Workloads & Execution

Evaluating modern Single-Page Applications (SPAs) requires validating both backend performance and frontend user experience under stress. If you only run protocol-level tests (hitting API endpoints directly), you are only seeing half the picture. Your APIs might respond in 50 milliseconds, but if your React app is bogged down by massive JavaScript bundles, heavy rendering pipelines, and blocking main threads under load, your actual users will experience a sluggish, frustrating interface.

To measure the true user experience, you must run hybrid load tests. Let us compare the two main approaches for capturing frontend performance during backend stress tests.

Two Approaches for Frontend Validation

When designing a hybrid testing pipeline, you have two primary options for structuring your execution runtimes:

Approach A: The Native k6/browser Module. This approach executes headless browser interactions directly within your k6 script. The k6/browser module uses an API modeled after Playwright, communicating with headless Chromium instances using the Chrome DevTools Protocol (CDP). Both protocol VUs and browser VUs run within the same k6 process, consolidating backend latency metrics and frontend rendering metrics into a single unified data stream.

Approach B: Standalone Parallel Orchestration. This approach runs a standard k6 protocol test in the background while running a standalone Playwright test suite in parallel. While this requires coordinating two separate runtimes (Go/Sobek and Node.js/V8), it allows you to reuse your existing Playwright integration suites without translating them into k6 syntax. You orchestrate execution through a CI/CD script, kicking off both tasks simultaneously.

The Brutal Math of Resource Balancing

While native browser-level testing is incredibly powerful, it has a massive cost: system resources. As we discussed in our concurrency analysis, a protocol-level VU is lightweight, requiring between 1MB and 5MB of RAM. A browser-level VU, however, must run a full instance of headless Chromium. A single Chromium instance can consume between 200MB and 400MB of memory, plus an entire CPU core.

If you try to run a 1000-user load test entirely using browser VUs, you will crush your load generator. You would need a massive, expensive server cluster just to launch the browsers. To optimize resource consumption, use the hybrid pattern: allocation-shifting.

Use protocol-level VUs to generate 99% of the target load against your backend APIs, stressing the servers and databases. In parallel, allocate a tiny pool of browser VUs (typically 1 to 5) to navigate the web interface, capturing core Web Vitals under stress (such as Largest Contentful Paint, Cumulative Layout Shift, and First Input Delay).

Writing a Production-Grade Hybrid Script

Here is how you write a hybrid test script in k6 that combines constant background API load with isolated browser interactions, enforcing Web Vital performance thresholds:

import { browser } from 'k6/browser';
import { expect } from 'https://jslib.k6.io/k6-testing/0.6.1/index.js';
import http from 'k6/http';
import { check } from 'k6';

export const options = {
  scenarios: {
    // 1. High-volume background stress (Protocol Level)
    backend_load: {
      executor: 'constant-arrival-rate',
      rate: 50,
      timeUnit: '1s',
      duration: '1m',
      preAllocatedVUs: 20,
      maxVUs: 100,
      exec: 'runProtocolLoad',
    },
    // 2. Minimal browser execution (Frontend UX Level)
    frontend_ux: {
      executor: 'shared-iterations',
      vus: 2, // Minimal footprint to preserve system RAM
      iterations: 5,
      exec: 'runBrowserUX',
      options: {
        browser: {
          type: 'chromium',
        },
      },
    },
  },
  thresholds: {
    'browser_web_vital_lcp': ['p(95)<2500'], // LCP under 2.5s
    'http_req_duration': ['p(95)<300'],
  },
};

export function runProtocolLoad() {
  const params = { headers: { 'Content-Type': 'application/json' } };
  const res = http.post('https://quickpizza.grafana.com/api/pizza', '{}', params);
  check(res, { 'api_success': (r) => r.status === 201 });
}

export async function runBrowserUX() {
  const context = await browser.newContext({
    viewport: { width: 1920, height: 1080 },
  });
  const page = await context.newPage();

  try {
    await page.goto('https://quickpizza.grafana.com/');
    const heading = page.locator('h1');
    await expect(heading).toBeVisible({ timeout: 5000 });
    
    const orderBtn = page.locator('button[name="pizza-please"]');
    await orderBtn.click();
    
    const pizzaName = page.locator('h2[id="pizza-name"]');
    await expect(pizzaName).toBeVisible({ timeout: 8000 });
  } finally {
    await page.close();
    await context.close();
  }
}

This hybrid execution model is the gold standard for modern application verification. It lets you validate that your infrastructure can handle the load, while proving that your users can still interact with a fast, responsive interface under peak stress.

To learn how to compile custom k6 binaries that support these advanced browser capabilities, read xk6 Architecture: Extending the k6 Runtime with Custom Go Bindings. If you want to configure real-time metrics dashboards, check out k6 Reporting: Real-time Web Dashboards and Headless NDJSON Streaming. If you are deploying this hybrid testing setup on Windows, see our framework guide on k6 Workload Execution and static Gzip HTML Report Generation on Windows.