PRACTICAL GUIDE / Playwright response HTTP version testing

Verify the protocol the browser actually used

Use Playwright to detect HTTP protocol downgrades, test controlled HTTP/1.1 and HTTP/2 endpoints, and separate real wire evidence from mocks.

By The Testing AcademyUpdated August 7, 202621 min read
All field guides
In this guide9 sections
  1. Know what the response value represents
  2. Prove the method against a controlled HTTP/1.1 server
  3. Test HTTP/2 with a server that owns negotiation
  4. Separate redirects, workers, and mocks from the target hop
  5. Diagnose a downgrade with evidence from the same attempt
  6. Separate an edge downgrade from TLS inspection
  7. Roll out a transport gate only where it earns its cost
  8. Assign the incident before the gate pages anyone
  9. Name the blind spot: multiplexing

What you will learn

  • Know what the response value represents
  • Prove the method against a controlled HTTP/1.1 server
  • Test HTTP/2 with a server that owns negotiation
  • Separate redirects, workers, and mocks from the target hop

The CDN dashboard says HTTP/2 is enabled, but one CI region still reaches the page over HTTP/1.1. Status, headers, and body all look correct, so the normal assertions stay green. The downgrade only becomes visible when the test records the protocol used for the exact response that rendered the page.

That observation is narrower than a performance claim. It can prove what the browser used for one hop and one response. It cannot prove how the CDN contacted the origin, why negotiation selected that protocol, or whether HTTP/2 made the page faster.

Know what the response value represents

Playwright 1.59 added the asynchronous response.httpVersion() method to the page network Response class. The object comes from browser activity such as page.goto(), page.waitForResponse(), or a page.on('response') listener. It is not the APIResponse returned by the request fixture or page.request.

The basic call is intentionally small:

TypeScript
const response = await page.goto('https://staging.example.test/health');
if (!response) throw new Error('Navigation did not produce an HTTP response');

const version = await response.httpVersion();
console.log(version);

Current Playwright normalizes the two common protocol strings. A browser protocol value of http/1.1 becomes HTTP/1.1, and h2 becomes HTTP/2.0. Other reported values are returned without that conversion, so an HTTP/3 response is commonly h3. Assert the values returned by Playwright, not the lowercase ALPN labels copied from a browser networking article.

HTTP version is not an HTTP response header. Adding X-HTTP-Version: h2 at a proxy proves only that a component wrote that header. A stale cache, misconfigured hop, or test mock can repeat it while the browser uses a different protocol. The browser learns the transport through connection negotiation and its network stack, then Playwright exposes the observed result.

For HTTPS, Application-Layer Protocol Negotiation lets the client and server select an application protocol during TLS setup. Common identifiers include http/1.1, h2, and h3. HTTP/3 can also be advertised as an alternative service for later connections. That is why the first visit to an origin may use HTTP/2 while a subsequent visit uses HTTP/3. A test that requires h3 on the first fresh profile can fail even though the deployment is operating as designed.

The value applies to the browser-facing hop represented by that response. A forward proxy can change what the browser negotiates. A CDN can use HTTP/2 toward the browser and HTTP/1.1 toward the application server. httpVersion() does not expose the private upstream hop. Name the boundary accurately in the test title, for example, "browser to public edge uses HTTP/2 or HTTP/3."

Status and protocol answer different questions. A 404 can arrive correctly over HTTP/2. A 200 can arrive over an unexpected HTTP/1.1 connection. Playwright treats HTTP error status codes as valid responses, so the test can inspect both fields without turning every 4xx response into a navigation exception.

Prove the method against a controlled HTTP/1.1 server

A deterministic baseline should not depend on a public website. Node's http server speaks HTTP/1.1, so it gives the test a known origin and makes an excellent compatibility check after upgrading Playwright.

TypeScript
// tests/network/http-version-http1.spec.ts
import { once } from 'node:events';
import { createServer, type Server } from 'node:http';
import { expect, test } from '@playwright/test';

let server: Server;
let origin: string;

test.beforeAll(async () => {
  server = createServer((request, response) => {
    if (request.url === '/health') {
      response.writeHead(200, { 'content-type': 'application/json' });
      response.end(JSON.stringify({ status: 'ok' }));
      return;
    }

    response.writeHead(404, { 'content-type': 'text/plain' });
    response.end('not found');
  });

  server.listen(0, '127.0.0.1');
  await once(server, 'listening');

  const address = server.address();
  if (!address || typeof address === 'string')
    throw new Error('Expected a TCP server address');
  origin = `http://127.0.0.1:${address.port}`;
});

test.afterAll(async () => {
  await new Promise<void>(resolve => server.close(() => resolve()));
});

test('reports HTTP/1.1 from the local server', async ({ page }, testInfo) => {
  const response = await page.goto(`${origin}/health`);
  expect(response).not.toBeNull();

  const version = await response!.httpVersion();
  const observation = {
    url: response!.url(),
    status: response!.status(),
    version,
    fromServiceWorker: response!.fromServiceWorker(),
    server: await response!.serverAddr(),
  };

  await testInfo.attach('transport-observation.json', {
    body: JSON.stringify(observation, null, 2),
    contentType: 'application/json',
  });

  expect(observation.status).toBe(200);
  expect(observation.version).toBe('HTTP/1.1');
  expect(observation.fromServiceWorker).toBe(false);
});

The attachment from a typical run looks like this, with the chosen ephemeral port varying:

JSON
{
  "url": "http://127.0.0.1:53142/health",
  "status": 200,
  "version": "HTTP/1.1",
  "fromServiceWorker": false,
  "server": {
    "ipAddress": "127.0.0.1",
    "port": 53142
  }
}

Retaining a five-field JSON record is more useful than a bare assertion error. It tells a reviewer which URL produced the value, whether a worker handled it, and which address accepted the connection. It is also safer than uploading a full HAR that may contain cookies, authorization headers, query tokens, or response bodies unrelated to the transport check.

This baseline catches two common mistakes. Calling httpVersion() without await compares a Promise with a string. Calling it on request.get() fails at type checking because APIResponse has no such method. Keep the browser and API client cases separate rather than casting away the compiler error.

Test HTTP/2 with a server that owns negotiation

A real HTTP/2 assertion needs TLS and an HTTP/2-capable endpoint. For a local test, create a short-lived certificate once for the fixture directory. The certificate is test data, not a production secret.

Shell
mkdir -p tests/certs
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout tests/certs/local-key.pem \
  -out tests/certs/local-cert.pem \
  -days 2 -subj '/CN=localhost' \
  -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1'

Node's secure HTTP/2 server advertises h2 through ALPN. Playwright reports that negotiated result as HTTP/2.0.

TypeScript
// tests/network/http-version-http2.spec.ts
import { once } from 'node:events';
import { readFileSync } from 'node:fs';
import {
  createSecureServer,
  type Http2SecureServer,
} from 'node:http2';
import { expect, test } from '@playwright/test';

let server: Http2SecureServer;
let origin: string;

test.use({ ignoreHTTPSErrors: true });

test.beforeAll(async () => {
  server = createSecureServer({
    key: readFileSync('tests/certs/local-key.pem'),
    cert: readFileSync('tests/certs/local-cert.pem'),
    allowHTTP1: true,
  });

  server.on('request', (request, response) => {
    response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
    response.end(`<h1>Protocol fixture</h1><p>${request.url}</p>`);
  });

  server.listen(0, '127.0.0.1');
  await once(server, 'listening');

  const address = server.address();
  if (!address || typeof address === 'string')
    throw new Error('Expected a TCP server address');
  origin = `https://localhost:${address.port}`;
});

test.afterAll(async () => {
  await new Promise<void>(resolve => server.close(() => resolve()));
});

test('negotiates HTTP/2 with the TLS fixture', async ({ page }) => {
  const response = await page.goto(`${origin}/report`);
  if (!response) throw new Error('Expected a document response');

  expect(await response.httpVersion()).toBe('HTTP/2.0');
  await expect(page.getByRole('heading', { name: 'Protocol fixture' })).toBeVisible();
});

allowHTTP1: true gives the server a fallback, which is useful for diagnosis. If the assertion reports HTTP/1.1, the page can still load and leave an artifact instead of failing during connection setup. The failure then says negotiation downgraded, not that the application was unreachable.

Keep the self-signed certificate exemption local to this project or file. Turning ignoreHTTPSErrors on for every test can hide expired, mismatched, or untrusted certificates in environments where certificate validation is part of the product contract.

A public edge test often needs a policy rather than one exact version. If the requirement is "do not downgrade below HTTP/2," accept Playwright's normalized HTTP/2 value and the HTTP/3 family while preserving the actual value:

TypeScript
function isHttp2OrNewer(version: string): boolean {
  return version === 'HTTP/2.0' || version === 'h3' || version.startsWith('h3-');
}

test('public edge avoids HTTP/1.1', async ({ page }, testInfo) => {
  const response = await page.goto('https://staging.example.test/');
  if (!response) throw new Error('Expected a document response');

  const version = await response.httpVersion();
  await testInfo.attach('edge-protocol.txt', {
    body: `${response.url()} ${response.status()} ${version}\n`,
    contentType: 'text/plain',
  });

  expect(isHttp2OrNewer(version)).toBe(true);
});

Do not broaden the accepted set until a failing observation has an owner and an explanation. Adding HTTP/1.1 after a regional proxy failure makes the gate meaningless. If that proxy is intentionally outside the contract, exclude its project explicitly or convert that project to monitoring.

The document and its critical API can also negotiate differently. They may use separate hosts, CDN products, or proxy routes. Waiting for the API response before the click preserves the causal link and prevents an unrelated background request from satisfying the assertion:

TypeScript
import { expect, test } from '@playwright/test';

test('checkout API reaches the public edge over HTTP/2 or HTTP/3', async ({ page }, testInfo) => {
  await page.goto('https://checkout.staging.example.test/cart');

  const responsePromise = page.waitForResponse(response => {
    const url = new URL(response.url());
    return url.origin === 'https://api.staging.example.test'
      && url.pathname === '/v1/checkout/quote'
      && response.request().method() === 'POST';
  });

  await page.getByRole('button', { name: 'Calculate total' }).click();
  const response = await responsePromise;
  const version = await response.httpVersion();

  const record = {
    requestMethod: response.request().method(),
    url: response.url(),
    status: response.status(),
    version,
    fromServiceWorker: response.fromServiceWorker(),
    server: await response.serverAddr(),
  };

  await testInfo.attach('checkout-api-transport.json', {
    body: JSON.stringify(record, null, 2),
    contentType: 'application/json',
  });

  expect(response.status()).toBe(200);
  expect(response.fromServiceWorker()).toBe(false);
  expect(isHttp2OrNewer(version)).toBe(true);
});

Match origin, path, and method. A predicate containing only includes('/checkout') can capture telemetry, a preflight, or a previous GET. Starting waitForResponse() after clicking introduces a race because a fast cached response may arrive before the listener exists.

This API case still uses the browser's page network response. Replacing it with page.request.post() changes the client and returns APIResponse, which is useful for API behavior but does not expose httpVersion(). Do not cast that object to Response; the two classes have different transport ownership and capabilities.

Connection reuse adds sequence sensitivity. A browser may reuse an existing HTTP/2 connection for another request, or establish a fresh connection after idle timeout. When the contract concerns a cold connection, use a new browser context and make the target request first. When it concerns the user's warmed session, keep the documented warm-up navigation and attach both observations. Calling either sequence "the protocol for the site" is too broad.

Separate redirects, workers, and mocks from the target hop

page.goto() resolves with the first non-redirect response for the final navigation. A redirect chain can cross hosts, and each hop can use a different connection. Checking only the returned response can miss an HTTP/1.1 redirector in front of an HTTP/2 application.

Capture document responses as they occur and join each one to its redirect predecessor:

TypeScript
import { expect, test } from '@playwright/test';

type Hop = {
  url: string;
  status: number;
  version: string;
  redirectedFrom: string | null;
};

test('records every main-frame redirect hop', async ({ page }, testInfo) => {
  const pending: Array<Promise<Hop>> = [];

  page.on('response', response => {
    const request = response.request();
    if (!request.isNavigationRequest() || response.frame() !== page.mainFrame())
      return;

    pending.push((async () => ({
      url: response.url(),
      status: response.status(),
      version: await response.httpVersion(),
      redirectedFrom: request.redirectedFrom()?.url() ?? null,
    }))());
  });

  await page.goto('https://go.staging.example.test/start');
  const hops = await Promise.all(pending);

  await testInfo.attach('redirect-transport.json', {
    body: JSON.stringify(hops, null, 2),
    contentType: 'application/json',
  });

  expect(hops.at(-1)?.status).toBe(200);
  expect(hops.every(hop => hop.version !== 'HTTP/1.1')).toBe(true);
});

Filtering to the main frame prevents an iframe navigation from being mistaken for the top-level redirect. redirectedFrom() proves chain ownership. URL equality alone is weak because the same resource may be requested by a frame, preload, service worker, or retry.

A route fulfilled with page.route() is a near-miss that looks convenient. It lets the application exercise response handling, but no public edge negotiated that synthetic body. Playwright may have no protocol metadata for the fulfilled response and can fall back to a default representation. Use mocks to test application states, never to certify the network protocol of the origin.

Service workers create a second near-miss. A page-owned response can be fulfilled by a service worker's fetch handler. response.fromServiceWorker() tells you this happened. The worker may return a cached body without contacting the edge, or it may perform a separate network request owned by the worker. A single protocol string on the frame response does not automatically describe the worker-to-edge fetch.

For an edge transport project, configure a clean component:

TypeScript
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'edge-transport-chromium',
      testMatch: /transport\.spec\.ts/,
      use: { serviceWorkers: 'block' },
    },
  ],
});

Blocking service workers changes application behavior, so do not reuse this project as proof that the offline experience works. Keep a separate worker-enabled project for that contract and inspect worker-owned requests through browser-context network events.

Browser cache can also remove the connection you thought you were testing. Use a new browser context for a one-shot transport probe and avoid priming the same URL in setup. If the product requirement concerns a warm HTTP/3 path advertised through Alt-Svc, do the opposite: document the warm-up request, then assert the second request. The test should make its connection history deliberate.

The browser's Resource Timing API can provide a useful countercheck for subresources. Its nextHopProtocol field uses ALPN-style values such as http/1.1, h2, and h3, so its spelling differs from Playwright's normalized HTTP/1.1 and HTTP/2.0 strings. Cross-origin entries can expose an empty value unless the response grants timing access. That limitation is evidence about Resource Timing visibility, not proof that no protocol was used.

TypeScript
const browserTiming = await page.evaluate((resourceUrl) => {
  const entry = performance
    .getEntriesByName(resourceUrl)
    .find(item => item.entryType === 'resource') as PerformanceResourceTiming | undefined;
  return entry?.nextHopProtocol ?? null;
}, 'https://static.staging.example.test/app.js');

console.log({ browserTiming }); // For example: { browserTiming: 'h2' }

Use this comparison to catch an identity mistake. If the Playwright response says HTTP/2.0 for the document while Resource Timing says h3 for app.js, both can be correct because they describe different resources. Join on the exact URL and initiation, and expect spelling normalization before comparing. Prefer the Playwright response object for the assertion because it already carries request, status, frame, redirect, service-worker, server-address, and protocol evidence together.

An empty Resource Timing value is not a reason to add Timing-Allow-Origin to production solely for a test. That header changes what page scripts can observe. If the application has no product need for it, keep transport inspection on the automation side.

Diagnose a downgrade with evidence from the same attempt

Run the narrow project without retries while investigating. A retry can establish a new connection, learn an alternative service, select another proxy node, and turn the second attempt green.

Shell
npx playwright test tests/network/transport.spec.ts \
  --project=edge-transport-chromium --workers=1 --retries=0 --trace=on

Open the trace and select the navigation action. The Network tab identifies the request URL, redirect path, status, headers, timing, and body. Use it to confirm that the assertion examined the intended document rather than an API call or iframe. Do not expect the trace UI alone to replace the explicit protocol attachment. Keep httpVersion(), fromServiceWorker(), serverAddr(), and securityDetails() together in the test result.

A useful failure record looks like this:

Example
url=https://checkout.staging.example.test/
method=GET
status=200
httpVersion=HTTP/1.1
fromServiceWorker=false
server=203.0.113.41:443
tls=TLS 1.3
project=edge-transport-chromium
retry=0

Read the fields as one observation. Begin with url and the request method. A healthy pair names the exact document or browser-initiated operation in the contract. A broken pair may reveal a redirect landing page, preflight, or background request. The expected hostname with the wrong path is especially misleading because its protocol can look authoritative. Status is only a correlation field: 200 beside either HTTP/2.0 or HTTP/1.1 says the application replied, not that negotiation met policy.

HTTP/2.0 is healthy when the contract requires HTTP/2 or newer, while HTTP/1.1 is the direct broken value. An h3 value can mislead in a cold-connection test if setup already taught the browser about the alternative service. The value is real, but the sequence is wrong.

fromServiceWorker=false is healthy for a direct edge probe. true gives the response the wrong owner. False rules out worker fulfillment, but not a forward proxy, TLS inspection, a CDN cache, or connection reuse.

The server field is healthy when its address maps to an expected public edge pool. A broken address may identify one lagging member or an interception point. Anycast, DNS, and normal pool rotation make an unfamiliar address misleading until it is correlated with region and time.

TLS 1.3 beside HTTP/1.1 is consistent, not contradictory. Modern TLS can succeed while application-protocol negotiation selects HTTP/1.1. A green retry is also misleading: another attempt negotiated acceptably, but the original connection remains unexplained.

Read project and retry before comparing runs. A healthy comparison holds browser project, region, and attempt policy constant. Different browser projects can traverse different proxy paths, while retry 1 may reuse knowledge or select another node. Those values explain why apparently adjacent records are not equivalent, even when URL and status match. Together, the fields distinguish response selection, connection history, and infrastructure routing from the protocol result itself.

That record rejects several guesses. TLS succeeded, a service worker did not synthesize the page, and the expected host returned 200. It does not identify why ALPN selected HTTP/1.1. Compare the server address and CI region with a passing run, then inspect the public load balancer or proxy configuration for that route.

Separate an edge downgrade from TLS inspection

Two incidents can produce almost the same log. One public edge member may offer only HTTP/1.1. Alternatively, a CI egress appliance may terminate TLS and offer HTTP/1.1 to the browser. Both can return the expected URL, status 200, correct body, fromServiceWorker=false, and TLS 1.3.

Capture the optional issuer and subjectName values from securityDetails() on the same response when the browser exposes them. An edge-listener defect retains the public certificate identity expected for the hostname. Its address maps to the edge pool, and the downgrade follows that address from runners that do not share the original CI egress path.

TLS interception normally shows the organization's inspection authority as issuer, and the address may map to its egress layer. If the failure follows the runner across several controlled HTTP/2-capable public origins while a clean runner reaches the target over HTTP/2 or HTTP/3, the runner's launch policy or egress path is implicated. An inspection issuer separates TLS interception from a browser-only difference. One hostname failing through independent routes, tied to the same edge address and public certificate, points to the edge.

Certificate chains can rotate, so use issuer as correlation evidence rather than a permanent allowlist. Preserve time, region, address, and certificate identity together. A later green run without that context cannot distinguish repair from different routing.

Use a small hypothesis table during incident review:

ObservationLikely next checkWhat not to conclude
Only one server address returns HTTP/1.1Edge pool configuration and rolloutThe application server disabled HTTP/2 everywhere
First request is HTTP/2, second is h3Alternative-service advertisement and cacheThe first request is necessarily broken
fromServiceWorker is trueWorker cache and worker-owned requestThe public edge used the reported frame protocol
Mocked route returns the expected bodyApplication response handlingAny real network protocol was negotiated
Local proxy project downgradesProxy capability and CONNECT behaviorProduction users see the same hop
HTTP/2 is present but latency is highTiming, multiplexing, server work, and payloadProtocol selection guarantees performance

If a failure appears only in a multi-browser matrix, check support and network architecture before filing a browser bug. The same corporate proxy may expose different paths to Chromium, Firefox, and WebKit. Record project name, browser version, operating system, proxy settings, and endpoint. A protocol assertion without that context is difficult to reproduce.

Roll out a transport gate only where it earns its cost

Start by raising the suite's minimum Playwright version to 1.59 or later and let TypeScript find old response wrappers that erase the concrete Response type. Add one local HTTP/1.1 baseline and one controlled HTTP/2 fixture. Those tests verify the test mechanism before any environment-specific policy can page an infrastructure team.

Run public-edge checks in observation mode for several days. Attach the compact record but do not fail on version. Group results by endpoint, browser project, CI region, and server address. This establishes whether the supposed contract is stable and reveals intentional exceptions such as developer proxies or disaster-recovery pools.

Keep observation output machine-readable. A reporter or later job can aggregate the attached JSON without scraping assertion messages. Store no cookies, authorization values, request bodies, or full query strings. For endpoints whose query parameters contain identifiers, retain the origin and pathname and hash any case id needed for correlation.

Promote endpoints one at a time. Begin with a health document owned by the edge team, then add a critical HTML navigation, then a browser-initiated API call. Each addition exercises a different route through the infrastructure. If the health check is HTTP/2 but the API is HTTP/1.1, the evidence points to host or route configuration rather than a browser-wide failure.

During a CDN migration, run the old and new hostnames as separate tests rather than putting fallback logic in one assertion. A conditional that accepts whichever host responds can conceal a partial rollout. Separate case identities show whether both edges meet their declared policy and let the owner retire the old case when traffic moves.

Turn a check into a gate only after the service owner states the accepted values and owns failures. Keep retries disabled for the transport project or preserve every attempt separately. A passing retry is evidence of inconsistent negotiation, not proof that the first result was harmless.

For an established suite, land evidence retention before the assertion. First produce one unambiguous observation for the target operation, then preserve it on passing and failing runs. Turn the policy red only after both changes are visible in CI. Otherwise the first downgrade has no route, worker, address, or certificate context, and a rerun may choose another connection.

Shared setup usually breaks first. An existing page.route() can fulfill the target, a warm-up can consume the intended cold connection, and a late listener can miss a fast response. Move the case into its narrow project before enforcing it while functional projects retain their mocks and worker behavior.

The change works when the controlled fixtures produce opposite values, each case emits one record for the named operation, and every public observation has a route and owner. Empty records, multiple candidates, worker ownership, or synthetic fulfillment are harness failures, not edge incidents.

The cost is more than the few milliseconds needed to call httpVersion(). A meaningful matrix may require dedicated regions, fresh contexts, certificate fixtures, and infrastructure triage. HTTP/3 checks can require warm-up requests and are sensitive to UDP policy. Every added environment increases runtime and operational ownership.

In an illustrative matrix, three endpoints across three regions in cold and warmed states create 18 navigations. If fresh context and navigation add an illustrative 800 milliseconds each, that is at least 14.4 serial seconds before application time and artifact upload. Parallelism consumes more runners without reducing edge traffic. One warm-up followed by one measured navigation also doubles the request count for that HTTP/3 case.

Blocking service workers removes the user's worker-controlled path from this project, requiring a separate worker-enabled contract. Fresh contexts cannot represent long-lived tabs. Certificate fixtures rotate, predicates change when routes move, and regional projects need maintenance when CI topology changes.

Assign the incident before the gate pages anyone

Automation owns response identity, listener timing, fixtures, and records. The application team owns redirects, hostnames, request initiation, and worker behavior. CI platform owns runner proxies, trust stores, egress, and regional differences. Edge or network owns the public listener, ALPN policy, and pool rollout. Select one initial owner from the evidence.

Edge receives a downgrade that follows a public address and expected certificate across independent runners. CI platform receives one that follows a runner or inspection identity across unrelated origins. Automation keeps mismatched URL, method, attempt, or worker evidence. Application receives intentional path changes from redirects or workers.

The handoff needs the accepted policy, scrubbed origin and path, UTC time, CI region, browser project and version, attempt, protocol, worker flag, server address, TLS protocol and issuer, and any redirects. Include a nearby pass and the controlled-fixture result from the same runner. Link the trace without copying credentials or bodies. State whether failure follows an address, runner path, or hostname.

Name the blind spot: multiplexing

This technique does not catch failed connection reuse or ineffective multiplexing. Many subresources can report HTTP/2.0 while requests serialize or use more connections than expected. The version label has no connection identity and does not prove that two responses shared one session.

Multiplexing, connection churn, and latency require their own connection and timing evidence. A green version gate can coexist with packet loss, server queuing, oversized payloads, or poor cache behavior.

Do not gate the version for a third-party script, font host, analytics endpoint, or identity provider you do not control. Monitor it if the information helps diagnosis, but an external rollout should not block unrelated application releases. The same rule applies to local development traffic deliberately served by a simple HTTP/1.1 server.

Skip this assertion when the real question is functional correctness. A JSON schema test should not fail because the response arrived over HTTP/1.1. A performance budget should measure timing and user impact rather than assume a protocol label guarantees speed. A security test should verify TLS and certificate requirements directly rather than use HTTP/2 as a proxy for security.

WebSocket handshakes, service-worker offline responses, and mocked routes each have their own lifecycle. Forcing them into a document-response protocol rule produces attractive but misleading coverage. Keep the transport probe narrow: one controlled browser request, one clearly named hop, one retained observation, and one owner who can act when negotiation changes.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 7, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

What does response.httpVersion return in Playwright?

The method returns the protocol reported for that browser response. Current Playwright normalizes common values to `HTTP/1.1` and `HTTP/2.0`; HTTP/3 is normally reported with its protocol identifier, such as `h3`.

Can I call httpVersion on an APIResponse from the request fixture?

No. The method belongs to the page network `Response` class, not `APIResponse`. Use a browser navigation or browser-initiated fetch when the protocol negotiated by the browser is the fact under test.

Why does my mocked response report HTTP/1.1?

A route fulfilled inside Playwright did not travel over the origin connection you meant to inspect. Treat any version on that synthetic response as test plumbing, and run the transport assertion against a real controlled endpoint.

How can I tell whether a service worker handled the response?

Check `response.fromServiceWorker()` on the same response and retain that value with the URL, status, and protocol. If the edge connection is the subject, use a fresh context with service workers blocked or explicitly reject service-worker responses.

Should CI fail whenever an endpoint uses HTTP/1.1?

Only a documented transport contract should become a gate. Third-party hosts, developer proxies, first-visit HTTP/3 behavior, and environments outside your control are better monitored as evidence than treated as deterministic failures.