When a Playwright test fails with net::ERR_CONNECTION_REFUSED, that error is telling you exactly which layer of the network stack broke: TCP, meaning nothing was listening on that port when the test tried to connect. The four steps every request takes are DNS lookup, TCP connection, TLS handshake, and HTTP exchange, and each layer has its own failure signature. This article covers each step, the status codes and headers that appear in real test failures, how cookies drive session state in Playwright, and how to read network traffic in the trace viewer.

What happens when Playwright opens a page

When your test runs await page.goto('https://lab.becomeqa.com'), four things happen in sequence:

1. DNS lookup

Your computer asks a DNS server: "What IP address is lab.becomeqa.com?"

The DNS server responds with something like 104.21.8.42. Domain names are human-readable aliases. The actual network uses IP addresses.

What breaks here:
  • ERR_NAME_NOT_RESOLVED: DNS couldn't find the domain. Either the domain doesn't exist, your DNS server is down, or (in CI) the container can't reach external DNS.
  • In CI environments, internal hostnames like api.internal need the CI network configured correctly for DNS to work.

2. TCP connection

Your computer opens a TCP connection to that IP address on a port number.

  • Port 443 = HTTPS (encrypted)
  • Port 80 = HTTP (unencrypted)
  • Port 3000, 8080, etc. = development servers
What breaks here:
  • ERR_CONNECTION_REFUSED: nothing is listening on that port. The server isn't running, or you're hitting the wrong port.
  • ERR_CONNECTION_TIMED_OUT: the server is unreachable (firewall, wrong network, server down).
  • In CI, this often means your app server didn't finish starting before the test ran.

3. TLS handshake (HTTPS only)

Browser and server exchange certificates and negotiate encryption. This is the "S" in HTTPS.

What breaks here:
  • ERR_CERT_AUTHORITY_INVALID: the SSL certificate is self-signed or expired. Common in staging environments.
  • ERR_SSL_PROTOCOL_ERROR: TLS version mismatch or misconfiguration.
  • Playwright has ignoreHTTPSErrors: true in config to skip certificate validation for test environments:

// playwright.config.ts
use: {
    ignoreHTTPSErrors: true,  // for staging with self-signed certs
}

4. HTTP request and response

Now the actual content transfer happens. Your browser sends a request; the server sends back a response.

A request looks like this (simplified):

GET /dashboard HTTP/1.1
Host: lab.becomeqa.com
Cookie: session=abc123
Accept: text/html

A response looks like this:

HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 4521

<!DOCTYPE html>...

The status code tells you what happened.

HTTP status codes you'll encounter

| Code | Meaning | Common cause |

|------|---------|--------------|

| 200 | OK | Everything worked |

| 201 | Created | POST succeeded, resource was created |

| 204 | No Content | Success, no body (common for DELETE) |

| 301/302 | Redirect | Page moved, browser follows automatically |

| 400 | Bad Request | Request is malformed: wrong format, missing field |

| 401 | Unauthorized | Not authenticated: need to log in |

| 403 | Forbidden | Authenticated but not allowed: wrong role/permissions |

| 404 | Not Found | Resource doesn't exist at that path |

| 422 | Unprocessable Entity | Request format is valid but content fails validation |

| 429 | Too Many Requests | Rate limited |

| 500 | Internal Server Error | Bug in the server code |

| 502 | Bad Gateway | Server got a bad response from an upstream service |

| 503 | Service Unavailable | Server is down or overloaded |

| 504 | Gateway Timeout | Upstream service took too long to respond |

Why this matters for testing:

A 401 is a different bug than a 403. "Not logged in" vs "logged in but not authorized" look identical in the UI (both redirect to login or show an error), but they need different fixes.

A 422 means your request reached the server and the server understood it, but the data failed validation. A 400 means the server couldn't even parse the request.

HTTP methods: what each one means

Every request uses a method that describes the intended operation:

| Method | Use | Idempotent? |

|--------|-----|------------|

| GET | Read data, fetch a page, load a list | Yes. Calling it twice returns same result |

| POST | Create, submit a form, add a record | No. Calling it twice creates two records |

| PUT | Replace, update a whole resource | Yes. Calling it twice has same result |

| PATCH | Partial update, change one field | Usually yes |

| DELETE | Remove a resource | Yes. Deleting twice has same result |

Why this matters for testing:

If your test calls POST twice (e.g., a button click is registered twice), you should get two records in the database. If you're testing idempotency of a PUT endpoint, calling it twice should leave the system in the same state as calling it once.

When you see a bug where "the form submitted twice," you need to know if it was two POST requests or one. That's visible in the Network tab of DevTools.

Headers: metadata on every request and response

Headers carry metadata. They're not the main content, but they control how the request and response are handled.

Request headers you'll see in tests:

Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...   ← authentication token
Content-Type: application/json                   ← "I'm sending JSON"
Cookie: session=abc123; csrf_token=xyz           ← session cookies
Accept: application/json                         ← "I want JSON back"

Response headers you'll see:

Content-Type: application/json; charset=utf-8   ← response is JSON
Set-Cookie: session=abc123; HttpOnly; Secure     ← server sets a cookie
Cache-Control: no-store                          ← don't cache this response
Location: /dashboard                             ← redirect target (with 302)

In Playwright API tests:

test('API returns JSON content type', async ({ request }) => {
    const response = await request.get('https://lab.becomeqa.com/api/items');
    
    expect(response.status()).toBe(200);
    expect(response.headers()['content-type']).toContain('application/json');
});

Cookies and sessions: how "logged in" works

When you log in, the server creates a session and sends back a cookie:

Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Strict

Your browser stores this cookie and sends it with every subsequent request:

Cookie: session_id=abc123

The server reads the cookie, looks up the session, and knows who you are.

Why this matters for testing:

This is why Playwright's storageState works. It saves the cookies from a logged-in session to a file, then loads them into new browser contexts. The server sees the same cookie, thinks you're still logged in, and skips authentication.

// Save auth state after login
await page.context().storageState({ path: 'auth.json' });

// Load it in tests
use: {
    storageState: 'auth.json'
}

When a test fails with "redirected to login page," it means the session cookie expired, wasn't saved correctly, or the auth.json file is stale.

Reading network traffic in Playwright

Playwright can intercept and inspect every request your test makes:

test('login sends correct credentials', async ({ page }) => {
    // Listen for the login API call
    const loginRequest = page.waitForRequest('**/api/auth/login');
    
    await page.goto('/');
    await page.getByRole('button', { name: 'Login' }).click();
    await page.getByLabel('Username').fill('admin@becomeqa.com');
    await page.getByLabel('Password').fill('testpass123');
    await page.getByRole('button', { name: 'Submit' }).click();
    
    const request = await loginRequest;
    const body = request.postDataJSON();
    
    expect(body.email).toBe('admin@becomeqa.com');
});

And responses:

test('items API returns correct data structure', async ({ page }) => {
    // Intercept the API call made when the page loads
    const itemsResponse = page.waitForResponse('**/api/items');
    
    await page.goto('/dashboard');
    
    const response = await itemsResponse;
    expect(response.status()).toBe(200);
    
    const items = await response.json();
    expect(Array.isArray(items)).toBeTruthy();
});

Common test failure patterns and what they mean

"net::ERR_CONNECTION_REFUSED" in CI but not locally

Your app server isn't running when the test starts. Add a wait for the server to be ready, or use a webServer config in playwright.config.ts:

webServer: {
    command: 'npm start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
}

"Request failed: 401 Unauthorized"

Session expired or auth state not loaded. Check that storageState is configured, or that your global setup is running before tests.

"Request failed: 429 Too Many Requests"

Your test is hitting a rate limiter. Add delays between requests or disable rate limiting in test environments.

"Response timeout: 30000ms exceeded"

The server took longer than 30 seconds to respond. Either increase the timeout for that specific operation, or investigate why the server is slow (database query, external API call, etc.).

Test passes locally, fails in CI with a 500 error

Environment variable missing in CI: database URL, API key, feature flag. The server throws an uncaught error because a required config value is undefined.

The Network tab in DevTools

When debugging a failure, open Chrome DevTools → Network tab before running the flow manually. Every request appears with:

  • URL and method
  • Status code
  • Request headers and body
  • Response headers and body
  • Timing

This tells you exactly what your browser sent and what the server returned. If the UI shows an error but the Network tab shows a 200, the bug is in the frontend JavaScript interpreting the response. If the Network tab shows a 500, the bug is in the backend.

Playwright's trace viewer shows the same information for automated test runs: open the trace, switch to the Network tab, and see every request made during the test.

What you don't need to know (yet)

  • WebSockets and Server-Sent Events: real-time connections, relevant only for testing real-time features
  • HTTP/2 and HTTP/3: protocol versions; Playwright handles these transparently
  • TLS certificate management: relevant for DevOps, not day-to-day QA
  • Load balancing and CDN configuration: infrastructure knowledge for senior roles
  • OAuth 2.0 flow details: useful when testing auth features specifically

FAQ

Do I need to know networking to write Playwright tests?

Not deeply. But understanding the four-step flow (DNS → TCP → TLS → HTTP) lets you diagnose the class of error in 30 seconds instead of 30 minutes. You don't need to implement any of it. You just need to recognize which layer broke.

Should I check the Network tab or the test error message when a test fails?

Both, in order. The test error message tells you what assertion failed. The Network tab (or Playwright trace) tells you why: what the server actually returned. Error message first, network second.

What's the difference between a 401 and a 403?

401 = not authenticated (you haven't logged in). 403 = authenticated but not authorized (you're logged in, but you don't have permission for this resource). Both can result in a "you can't access this" response from the UI, but they're different bugs with different fixes.

→ See also: API Testing with Playwright: Beyond the UI | SQL for QA: The Queries You Actually Need