PRACTICAL GUIDE / Playwright connect exposeNetwork

When a remote Playwright browser cannot reach your local service

Learn how to expose only the test runner services a remote Playwright browser needs, diagnose false localhost failures, and limit the security cost.

By The Testing AcademyUpdated August 4, 202617 min read
All field guides
In this guide6 sections
  1. Why localhost points at the wrong machine
  2. Build the smallest working loopback bridge
  3. Match private hosts and ports without hiding real routing bugs
  4. Read the evidence before changing a timeout
  5. Roll the change out as a network permission
  6. Know when a tunnel is the wrong fix

What you will learn

  • Why localhost points at the wrong machine
  • Build the smallest working loopback bridge
  • Match private hosts and ports without hiding real routing bugs
  • Read the evidence before changing a timeout

The checkout test reaches a browser in another container, but its callback to http://127.0.0.1:4317 dies with ERR_CONNECTION_REFUSED. The same test passes when Chromium runs beside the test process. Nothing is wrong with the callback server. The two runs disagree about which machine owns 127.0.0.1.

That distinction matters whenever Playwright connects to a browser launched elsewhere. A browser in a Kubernetes pod, a remote workstation, or a browser grid has its own loopback interface and its own view of private DNS. browserType.connect() can deliberately expose selected destinations from the connecting client with the exposeNetwork option. The useful part is not turning the option on. The useful part is choosing the smallest rule that makes the test honest.

Why localhost points at the wrong machine

A normal local Playwright run has a simple topology. The test process starts an application or stub server, launches a browser on the same host, and navigates to a loopback URL. Both processes see the same network namespace, so http://localhost:3000 reaches the expected listener.

A remote run splits that topology:

  1. The Playwright client runs the test code and owns fixtures, assertions, and often the application under test.
  2. A browser server runs somewhere else and exposes a Playwright WebSocket endpoint.
  3. browserType.connect() attaches the client to that browser server.
  4. Network requests initiated by pages normally leave from the browser side.

Step four surprises teams because method calls still originate in the test file. page.goto() is invoked by the client, but Chromium, Firefox, or WebKit performs the navigation. If Chromium lives in container B, http://127.0.0.1:4317 names container B. A service listening in container A is invisible unless the environments share a network or Playwright forwards that destination.

The same mistake appears with private hostnames. Suppose a developer laptop resolves catalog.test.internal through a VPN. A remote browser host without that VPN either gets NXDOMAIN, resolves a public address, or stalls while its DNS resolver retries. Increasing the navigation timeout changes none of those outcomes. The browser is asking the wrong resolver from the wrong network.

exposeNetwork changes the route for matching destinations. The option is supplied when the client calls browserType.connect(endpoint, options). Its value is a comma-separated rule list. Playwright documents three useful forms:

  • Hostname patterns such as api.test.internal, *.staging.internal:8443, or *foo.example.
  • IP literals, optionally with a port, such as 127.0.0.1, 0.0.0.0:9090, [::1], or [0:0::1]:9090.
  • The special <loopback> token, which covers localhost, subdomains of localhost, IPv4 loopback, and IPv6 loopback.

An asterisk exposes all client-visible network destinations. It is convenient for a ten-minute experiment and usually too broad for a shared CI worker. The page can make requests to anything the client can reach under that rule. Same-origin policy limits what page JavaScript may read, but it does not turn unwanted network access into a safe design. Navigations, forms, images, and other requests can still reach sensitive endpoints.

This forwarding is not the same as the WebSocket used to control the remote browser. The endpoint must already be reachable by the client. Extra headers on browserType.connect() apply to the WebSocket connection, not automatically to application requests. exposeNetwork does not copy a VPN profile into the remote machine, edit DNS there, or make connectOverCDP behave like a Playwright protocol connection.

Version compatibility also sits outside the network rule. The Playwright documentation requires the connecting client and the browser server to have matching major and minor versions. A 1.61 client is compatible with a 1.61 server, regardless of patch number. If the connection fails before a page exists, investigate the endpoint, headers, firewall, and versions before looking at exposeNetwork.

Build the smallest working loopback bridge

The first example models a common CI arrangement. A callback server is created by the test runner, while the browser comes from a remote browser server whose WebSocket URL is stored in PW_WS_ENDPOINT. The test generates a per-run token so a stray service on the browser host cannot accidentally produce a green result.

TypeScript
import { createServer } from 'node:http';
import { once } from 'node:events';
import { randomUUID } from 'node:crypto';
import { chromium, expect, test } from '@playwright/test';

test('remote browser reaches the runner callback', async () => {
  const token = randomUUID();

  const callbackServer = createServer((request, response) => {
    if (request.url === '/callback?token=' + token) {
      response.writeHead(200, { 'content-type': 'text/plain' });
      response.end('callback accepted: ' + token);
      return;
    }

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

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

  const address = callbackServer.address();
  if (!address || typeof address === 'string')
    throw new Error('Expected a TCP listener');

  const wsEndpoint = process.env.PW_WS_ENDPOINT;
  if (!wsEndpoint)
    throw new Error('PW_WS_ENDPOINT is required');

  const browser = await chromium.connect(wsEndpoint, {
    exposeNetwork: '<loopback>',
    timeout: 30_000,
  });

  try {
    const context = await browser.newContext();
    const page = await context.newPage();

    const response = await page.goto(
      'http://127.0.0.1:' + address.port + '/callback?token=' + token
    );

    expect(response?.status()).toBe(200);
    await expect(page.locator('body')).toHaveText(
      'callback accepted: ' + token
    );

    await context.close();
  } finally {
    await browser.close();
    callbackServer.close();
    await once(callbackServer, 'close');
  }
});

The assertion checks more than a 200 response. It verifies the random token returned by the server created in this test. That detail catches a deceptive near-miss: a remote host might already have something listening on the same port. A status-only assertion could accept that unrelated service.

Binding to 127.0.0.1 is intentional. The callback is not published on every interface merely to satisfy the test. The <loopback> rule provides the route through the client connection, so the listener can remain local to the runner. This reduces accidental exposure at the operating-system level, although the selected remote browser can still reach it for the lifetime of the connection.

Cleanup belongs in finally because three resources outlive a failed assertion: the browser connection, the context, and the HTTP listener. In a production fixture, close the context in its own finally block as well. The compact example closes it in the successful path to keep the focus on network forwarding. A robust shared helper should tolerate partial setup, because chromium.connect() can throw after the listener has started.

A Playwright Test project can also place the setting in connectOptions when it always connects through a remote endpoint. That removes repeated connection code, but it broadens the lifetime of the exposure to the fixture-managed browser connection.

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

const wsEndpoint = process.env.PW_WS_ENDPOINT;
if (!wsEndpoint)
  throw new Error('PW_WS_ENDPOINT is required');

export default defineConfig({
  use: {
    connectOptions: {
      wsEndpoint,
      exposeNetwork: '<loopback>',
      timeout: 30_000,
    },
    trace: 'retain-on-failure',
  },
});

Central configuration is appropriate when every test worker needs the same boundary. It is a poor choice when only two tests need a local callback and the remaining two thousand tests should have no route into the runner. In that case, use a dedicated project or an explicitly created connection for the narrow group. Configuration convenience should not silently change the network authority of the entire suite.

Match private hosts and ports without hiding real routing bugs

Loopback is not the only failure. Many enterprise suites depend on services visible only through the test runner's VPN or split-horizon DNS. A rule can expose a hostname pattern and restrict it to a port.

The next test reaches an internal catalog through the client's network. It refuses to run if the URL does not use the expected HTTPS origin. That guard prevents an incorrectly populated secret from expanding the rule's practical scope.

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

test('catalog health comes from the runner VPN', async () => {
  const wsEndpoint = process.env.PW_WS_ENDPOINT;
  const catalogURL = process.env.CATALOG_TEST_URL;

  if (!wsEndpoint || !catalogURL)
    throw new Error('PW_WS_ENDPOINT and CATALOG_TEST_URL are required');

  const parsed = new URL(catalogURL);
  if (parsed.protocol !== 'https:' ||
      !parsed.hostname.endsWith('.test.internal') ||
      parsed.port !== '8443') {
    throw new Error(
      'CATALOG_TEST_URL must be https://*.test.internal:8443'
    );
  }

  const browser = await chromium.connect(wsEndpoint, {
    exposeNetwork: '*.test.internal:8443',
  });

  try {
    const context = await browser.newContext({
      ignoreHTTPSErrors: false,
    });
    const page = await context.newPage();

    const response = await page.goto(
      new URL('/health/ready', parsed).toString()
    );

    expect(response?.status()).toBe(200);
    await expect(page.getByText('catalog-ready')).toBeVisible();

    await context.close();
  } finally {
    await browser.close();
  }
});

This rule allows matching hosts only on port 8443. If the application redirects to https://login.test.internal on the default 443 port, the redirect leaves the exposed set and fails. That may look inconvenient, but it is valuable evidence. The test has revealed another dependency. Add login.test.internal:443 only after deciding that the remote browser should reach it. Do not replace the rule with an asterisk simply because the authentication architecture has more than one origin.

Certificate validation still applies. The forwarded route makes the service reachable; it does not make an untrusted internal certificate valid. A Chromium failure such as net::ERR_CERT_AUTHORITY_INVALID points to trust, not routing. Setting ignoreHTTPSErrors to true can prove that diagnosis in an experiment, but leaving it enabled discards coverage of the TLS boundary. Install the appropriate test certificate authority where the browser can use it, or keep a separate test that explicitly owns the decision to ignore certificate errors.

Port matching creates another useful negative test. If the product is allowed to call only the catalog's TLS listener, try its administrative port and assert that no successful application response is obtained. A test should avoid pinning a browser-specific error string because Chromium, Firefox, and WebKit report network failures differently.

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

test('remote page cannot reach the catalog admin port', async () => {
  const wsEndpoint = process.env.PW_WS_ENDPOINT;
  if (!wsEndpoint)
    throw new Error('PW_WS_ENDPOINT is required');

  const browser = await chromium.connect(wsEndpoint, {
    exposeNetwork: 'catalog.test.internal:8443',
  });

  try {
    const context = await browser.newContext();
    const page = await context.newPage();

    const result = await page
      .goto('https://catalog.test.internal:9443/admin', {
        timeout: 5_000,
      })
      .then(response => ({
        kind: 'response' as const,
        status: response?.status(),
      }))
      .catch(error => ({
        kind: 'error' as const,
        message: String(error),
      }));

    expect(result.kind).toBe('error');

    await context.close();
  } finally {
    await browser.close();
  }
});

That negative case assumes the admin endpoint is not independently reachable from the remote browser's network. Verify the assumption in the environment design. If the browser host already has a route to the admin service, exposeNetwork cannot remove it. The option adds access through the client for matching destinations; it is not an outbound firewall for all browser traffic.

Hostname rules also require disciplined naming. A suffix pattern such as *.test.internal is easier to maintain than a list of short hostnames, but it grants new subdomains access automatically. An explicit list costs maintenance and produces reviewable diffs when a dependency is added. For a payment or identity test environment, that friction is often worth keeping.

Read the evidence before changing a timeout

A routing failure has a recognizable sequence. The browser connection succeeds. The context and page are created. Navigation then fails before an HTTP status exists. A typical Chromium error looks like this:

Example
Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:4317/callback
Call log:
  - navigating to "http://127.0.0.1:4317/callback", waiting until "load"

DNS trouble usually names the resolution problem instead:

Example
Error: page.goto: net::ERR_NAME_NOT_RESOLVED at https://catalog.test.internal:8443/health/ready

Those strings are clues, not cross-browser contracts. Firefox may use NS_ERROR_UNKNOWN_HOST or NS_ERROR_CONNECTION_REFUSED. WebKit wording differs again. The important evidence is that no response status or response headers exist, and the destination is one whose location differs between the client and browser environments.

Run the failing test with a trace retained:

Shell
npx playwright test tests/remote-callback.spec.ts --trace=on
npx playwright show-trace test-results/remote-callback-*/trace.zip

In Trace Viewer, open the Network tab and select the failed document request. Confirm the exact scheme, host, and port. Check whether a redirect changed any of them. A rule for localhost:3000 does not cover 127.0.0.1:3000 unless the selected rule semantics cover both, and a host restricted to 8443 will not cover a redirect to 443. The call log shows where page.goto() waited; the network record shows what the browser actually requested.

At the same time, prove the listener exists on the client host. Run a request beside the Playwright process, not from your laptop if CI is the client:

Shell
curl --fail --verbose http://127.0.0.1:4317/health

For a fixed CI callback on port 4317, this preflight fails at the client-side boundary before the remote test opens a browser. The focused run keeps a trace if the listener is healthy but forwarding still fails.

Shell
#!/usr/bin/env bash
set -euo pipefail

: "${PW_WS_ENDPOINT:?PW_WS_ENDPOINT must name the browser server}"
curl --fail --silent --show-error \
  http://127.0.0.1:4317/health >/dev/null

npx playwright test tests/remote-callback.spec.ts \
  --project=remote-runner-services \
  --workers=1 \
  --trace=retain-on-failure

A successful curl plus a remote page refusal supports a topology diagnosis. A failed curl means the service is not ready, is bound to a different port, or has exited. exposeNetwork cannot forward to a listener that is absent.

Next, test from the browser host when you have access to it. If the same URL unexpectedly succeeds there, a duplicate service or shared network namespace may be masking the problem. Return a run-specific marker from local stubs and assert it, as in the first example. This is more reliable than inferring server identity from a common health payload.

Connection errors belong to a different layer. Messages about WebSocket handshake status, an invalid endpoint, or incompatible Playwright versions occur before page.goto(). Record both ends' versions:

Shell
npx playwright --version

Run that command in the client image and the image that launched the browser server. Matching package-lock files do not prove matching runtime images. A cached remote image may still carry 1.60 while the repository has moved to 1.61.

TLS failures have response-adjacent evidence such as certificate error codes, while an HTTP 401 or 403 proves routing succeeded far enough to receive an application response. Do not widen exposeNetwork for an authorization failure. Inspect request headers, cookies, origin policy, and the identity used by the test. Similarly, an HTTP 502 from a reverse proxy means the browser reached a server. The proxy's upstream route is now the failing boundary.

Service workers are another near-miss. A registered worker can satisfy a request from its cache, making the page appear to reach a service it never contacted. Use a fresh browser context for diagnosis, inspect the trace's network source, and consider serviceWorkers: 'block' in a focused routing test. Do not change the whole suite until you understand whether service worker behavior is part of the product requirement.

Proxy configuration can change the path as well. A browser context proxy, environment-level corporate proxy, or transparent grid proxy may resolve hosts outside the client. Write down the actual topology for the failing project. The phrase "remote browser" is too vague to diagnose whether the browser, grid sidecar, Playwright client, or corporate proxy owns DNS.

Roll the change out as a network permission

Treat an exposeNetwork edit like a firewall change. Start with an inventory from failing traces: every required hostname, IP form, and port, plus the reason the page reaches it. Separate browser traffic from test-process traffic. API calls made through a Node library in the fixture already leave from the client and do not need browser forwarding.

Create a dedicated smoke test for each allowed boundary. The loopback test should return a unique marker. A private hostname test should validate the expected certificate and application identity, not merely a 200. A redirecting login flow should assert the final URL so a newly introduced origin becomes visible.

Then add one negative case around the boundary. If only 8443 is exposed, show that the administrative port is not available through the same rule. Remember that this cannot override direct connectivity from the remote environment. Where strong isolation matters, enforce egress policy outside Playwright and let the test confirm that policy.

Move the rule into a dedicated project after the proof works:

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

const wsEndpoint = process.env.PW_WS_ENDPOINT;
if (!wsEndpoint)
  throw new Error('PW_WS_ENDPOINT is required');

export default defineConfig({
  projects: [
    {
      name: 'remote-public',
      use: {
        ...devices['Desktop Chrome'],
        connectOptions: {
          wsEndpoint,
        },
      },
    },
    {
      name: 'remote-runner-services',
      testMatch: /remote-callback\.spec\.ts/,
      use: {
        ...devices['Desktop Chrome'],
        connectOptions: {
          wsEndpoint,
          exposeNetwork:
            '<loopback>,catalog.test.internal:8443,login.test.internal:443',
        },
      },
    },
  ],
});

This split creates operational cost. CI now schedules another project, caches may be colder, and engineers must choose the right project when adding a test. The benefit is that most pages do not receive broad access to the runner network. A project name also leaves better evidence in reports than a hidden environment-specific branch inside a shared fixture.

Keep the rule outside secrets when possible. Hostnames and ports are architecture, not credentials. If an environment variable supplies the rule, validate it against an allowlist before passing it to Playwright. Otherwise a typo or compromised CI variable can silently turn a narrow rule into an asterisk.

This configuration accepts only reviewed rules, even when CI supplies the requested list through an environment variable:

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

const wsEndpoint = process.env.PW_WS_ENDPOINT;
if (!wsEndpoint)
  throw new Error('PW_WS_ENDPOINT is required');

const allowedRules = new Set([
  '<loopback>',
  'catalog.test.internal:8443',
  'login.test.internal:443',
]);

const requestedRules = (process.env.PW_EXPOSE_NETWORK ?? '<loopback>')
  .split(',')
  .map(rule => rule.trim())
  .filter(Boolean);

if (requestedRules.length === 0)
  throw new Error('PW_EXPOSE_NETWORK must contain at least one rule');

const rejectedRules = requestedRules.filter(
  rule => !allowedRules.has(rule)
);
if (rejectedRules.length > 0) {
  throw new Error(
    'Unapproved exposeNetwork rule: ' + rejectedRules.join(', ')
  );
}

export default defineConfig({
  projects: [
    {
      name: 'remote-runner-services',
      testMatch: /remote-callback\.spec\.ts/,
      use: {
        connectOptions: {
          wsEndpoint,
          exposeNetwork: requestedRules.join(','),
        },
        trace: 'retain-on-failure',
      },
    },
  ],
});

Add a review checkpoint for new destinations. A small text file or config comment can name the owning team, expected port, and removal date for temporary services. The maintenance cost is deliberate. Network dependencies tend to become permanent when nobody owns them.

Measure the latency cost during rollout. Forwarded browser traffic travels through the Playwright client connection. Large downloads or video streams can consume bandwidth and add another hop. A local JSON callback is an excellent candidate. A multi-gigabyte media test is not. Compare navigation timing before and after forwarding in the same environment, and keep performance assertions out of a topology that adds variable tunnel latency.

Finally, stage the change. Run the dedicated project on one CI lane, retain traces on every failure, and compare at least one passing and failing attempt. Once the hostname list stabilizes, make the negative smoke test required. Avoid retrying connection-refused failures during this stage. A retry can hide service readiness races and make the network rule look reliable when the listener simply started late.

Know when a tunnel is the wrong fix

Do not use exposeNetwork to compensate for an application that should be deployed with a routable test URL. If every remote browser in every suite needs the service, publishing it behind controlled test ingress is usually clearer. The ingress can have authentication, TLS, access logs, rate limits, and an owner. A Playwright-specific tunnel couples availability to the test client.

Avoid it for production access. Exposing a runner's production VPN route to arbitrary page content increases the consequence of a compromised dependency or an accidental navigation. Use a purpose-built synthetic monitoring network with explicit egress controls instead.

Do not select an asterisk because the dependency graph is unknown. Unknown dependencies are exactly why the broad rule is dangerous. Capture traces, list redirects and subresources, then approve concrete destinations. If the list is huge because the page is a public website, the remote browser probably already has the correct public route and the problem lies elsewhere.

A tunnel is also the wrong answer for a service readiness race. If curl fails beside the test process until two seconds after the browser navigates, wait for the server's readiness signal before connecting or navigating. Forwarding changes reachability, not startup order.

Certificate errors need certificate work. Authentication errors need an identity. CORS errors need the application to authorize the page origin or a test that intentionally verifies rejection. None of those failures become correct when more network is exposed.

connectOverCDP users need a different topology. The documented options for CDP attachment do not include exposeNetwork. Put the browser and target on a shared network, publish a narrow reverse proxy, or launch a Playwright browser server and use browserType.connect() when Playwright protocol fidelity is required. Do not cast the options object to bypass TypeScript and hope an undocumented property works.

Finally, avoid forwarding when the requirement is to prove the application works from the user's network. A remote browser's inability to resolve an internal hostname may be the behavior under test. Routing it through a developer VPN would turn a valuable failure into a false pass. Decide whose network the browser is supposed to represent, and let that answer choose the topology.

// 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 4, 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

How do I let a remote Playwright browser reach localhost on the test runner?

Use the exposeNetwork option on browserType.connect() and start with the `<loopback>` rule. The rule forwards loopback destinations through the connecting Playwright client, so localhost refers to services beside the test runner rather than services beside the browser.

Does exposeNetwork make the WebSocket connection itself work?

A successful browser connection is a separate prerequisite. exposeNetwork controls destinations the connected browser can reach after browserType.connect() has attached; it does not repair an inaccessible wsEndpoint, authentication header, firewall, or Playwright version mismatch.

Why does localhost pass locally but fail in a remote browser?

Localhost is resolved from the network position of the process making the connection. Without forwarding, a browser in a container or cloud worker looks for the service inside that environment, not on the machine running the test client.

Is exposeNetwork: '*' safe in CI?

Prefer a narrow hostname, port, or `<loopback>` rule whenever possible. A wildcard gives browser traffic a path to every network destination visible to the client, which can expose CI metadata endpoints, databases, and internal control planes.

Can I use exposeNetwork with connectOverCDP?

No equivalent exposeNetwork option is documented for browserType.connectOverCDP(). For a CDP attachment, make the target service routable from the browser host, use an explicit reverse proxy, or connect through the Playwright protocol instead.