Skip to content
Work in progressnormalbrowser is in closed beta. Browser builds are not published yet, payments are off, and the pages here describe where it is going. Watch the repository or sign in to be told when it ships.
normalbrowser

Documentation

Everything needed to go from an empty directory to a working session, and the parts where this client deliberately differs from Playwright.

Install

Node 20.6 or newer. The package is the client; the browser builds are separate artefacts that install fetches for your platform, verifies by SHA-256 and caches. Windows today; Linux and macOS builds are planned.

npm install normalbrowser
npx normalbrowser install          # downloads Chrome and Firefox builds
npx normalbrowser doctor

doctor is the end-to-end check: it finds the binaries, launches them, establishes a session, confirms the native input device is present, and verifies the persona reached the page. Run it first, every later problem is easier to diagnose once it passes.

To use builds you manage yourself, set NORMALBROWSER_CHROME or NORMALBROWSER_FIREFOX to the executable path.

Other languages: pip install normalbrowser, or com.normalbrowser:normalbrowser from Maven Central. Each wrapper exposes the same Playwright-shaped API.

Choosing an engine

Both engines carry the same launch options. chromium is the default and the one most sites expect; firefox is the alternative for sites that profile Chromium specifically.

import { chromium, firefox } from 'normalbrowser';

const chrome = await chromium.launch();
const ff = await firefox.launch();

Human-like input

Pointer motion and keystroke timing are modelled on real people and generated inside the browser as trusted events. It is on by default. Turn it off when you want raw speed against systems you control:

const browser = await chromium.launch({ humanize: false });

License activation

Set the key in the environment, not in a source file. A key in source is a key in your git history.

export NORMALBROWSER_LICENSE_KEY=nb_live_XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX

The client validates on launch and caches the result. Two properties worth knowing:

  • The limit is sessions per machine, not machines. The client derives a stable machine id locally and reports it, so the dashboard can show where a key is in use, but there is no cap on how many machines share a key. What the plan sets is how many browser sessions may run at once on each of them.
  • Validation fails open on our outage. If the license service is unreachable, the client keeps running on its cached answer. An outage on our side is our fault, and taking down your production for it would be indefensible.

Your first script

import { chromium, expect } from 'normalbrowser';

const browser = await chromium.launch({
  headless: false,
  persona: { country: 'US', seed: 42 },
});

const page = await browser.newPage();
await page.goto('https://example.com');

await page.getByRole('link', { name: 'More information' }).click();
await expect(page.getByRole('heading')).toBeVisible();

await browser.close();

seed makes the identity reproducible: the same seed and country always produce the same device. Omit it and you get a fresh identity each launch.

Existing Playwright scripts

Three ways in, in order of least effort.

# 1. Nothing at all, the CLI installs the loader hooks for you
npx normalbrowser run ./my-script.js

# 2. Your own runner, hooks registered via Node
node --import normalbrowser/register ./my-script.js

# 3. Change the import
- import { chromium } from 'playwright';
+ import { firefox } from 'normalbrowser';

The first two redirect playwright, playwright-core, @playwright/test and patchright to this client, so a script that imports any of them runs unmodified.

Device personas

A persona is a self-consistent device identity: locale, timezone, screen, core count, memory, colour scheme, and the fingerprint seed the engine uses for canvas and audio noise. Self-consistent means generated together, a four-core machine will not claim 64 GB of memory, and a persona will not report an NVIDIA renderer on a host with integrated graphics.

const browser = await firefox.launch({
  persona: { country: 'DE', seed: 7 },
});

// Or pin one and reuse it across runs:
// npx normalbrowser persona --country=DE --seed=7

Named profiles keep a persona and its browser storage together, so a logged-in session survives a restart:

npx normalbrowser profile create shopper --country=GB
npx normalbrowser profile list

Proxies

HTTP and SOCKS5, with authentication, configured per context. Authenticated proxies are handled by a local relay, because Firefox has no non-interactive way to supply credentials.

const browser = await firefox.launch({
  proxy: {
    server: 'http://gate.provider.net:8000',
    username: 'user',
    password: 'pass',
  },
});

WebRTC leak-proofing is applied on every launch, proxy or not. ICE candidate gathering runs over UDP and bypasses an HTTP proxy entirely, so an unconfigured profile behind a proxy hands any page the host's real addresses, directly contradicting the exit IP.

Request interception

await page.route('**/*.{png,jpg,woff2}', (route) => route.abort());

await page.route('**/api/**', async (route) => {
  const response = await route.fetch();
  const body = await response.json();
  await route.fulfill({ json: { ...body, patched: true } });
});

Read this before enabling it on a scored target. Marionette has no CDP-style network pause, so interception works by pointing the browser at a local proxy that terminates TLS with a certificate generated per run. That means the origin sees the proxy's TLS stack rather than Firefox's. We measured the difference and publish it on the home page, on a site that fingerprints TLS, leave interception off.

The CA is installed only into that throwaway profile, never into the system trust store.

Engine differences

Both engines share the launch options, the persona model and the Playwright-shaped API. Where they differ today:

  • Chrome is the default. It carries the majority TLS and HTTP/2 shape on the web and is the engine most sites expect. Native input parity with Firefox is being brought across in the current build series; the object returned by launch() reports inputMode so you can check what you got.
  • Firefox speaks Marionette rather than CDP, so there is no automation channel visible from the page, and it has the longest-standing native input engine, trusted, cursorless events routed into cross-origin iframes.

If a site behaves differently between the two, that is worth a ticket; the goal is that the choice of engine is a preference, not a workaround.

Measuring your setup

Your exit IP and your target matter as much as the browser does, so measure your own configuration rather than trusting ours.

npx normalbrowser audit          # fingerprint contradictions, with severities
npx normalbrowser scoreboard     # public bot detectors, per signal
npx normalbrowser transport      # TLS/HTTP-2, direct vs intercepted
npx normalbrowser linkability    # can two identities be joined?

scoreboard --control=firefox runs the same probes against an unpatched build, which is what turns a raw pass count into a meaningful number. --save=run.json writes full provenance: version, binary, persona seed, locale, timezone, proxy state and every probe result.

AI agents (MCP)

The browser is exposed over the Model Context Protocol on stdio, with 27 tools covering navigation, snapshots, input and inspection.

npx normalbrowser mcp --headed

Snapshots return an accessibility tree with stable ref handles, so an agent addresses elements by reference rather than by guessing selectors.

License API

The client calls one endpoint. It is documented because Gamer customers can self-host it, and because you should be able to see exactly what leaves your network.

POST https://normalbrowser.com/api/license/validate
content-type: application/json

{
  "key": "nb_live_…",
  "machineId": "stable-local-id",   // optional; omit to check without claiming a seat
  "hostname": "ci-runner-3"         // optional, for the dashboard
}

A valid key returns:

{ "valid": true, "plan": "vip", "maxConcurrent": 200, "seats": 200, "seatsUsed": 3, "expiresAt": 1780000000000 }

A refused key also returns 200, with valid: false and a reason of malformed, unknown, revoked, canceled, expired or seat_limit, plus a message written to be shown to a human. The status code is reserved for transport problems, so a client can tell “your subscription lapsed” apart from “the license server is broken”, which is what makes failing open safe.

That request carries the key, a machine id and optionally a hostname. It does not carry URLs, page content, or anything about what you automate.

While paid sessions are running the client also reports how long, every few minutes, so the refund rule and the hours shown on your dashboard are based on something real:

POST https://normalbrowser.com/api/license/usage
content-type: application/json

{ "key": "nb_live_…", "machineId": "stable-local-id", "seconds": 300 }

seconds is wall-clock time since the previous report during which at least one paid session was running. Ten sessions for five minutes is five minutes. Nothing else is sent.

Differences from Playwright

BehaviourWhy it differs
abort() returns HTTP 403Destroying the socket made pages retry aggressively, one abort became ten requests. A refusal is what a blocked request looks like to a real client.
chromium is an alias for firefoxOn the compatibility path there is one engine, and most existing scripts import chromium, throwing there would defeat the whole point of running them unmodified. Nothing is concealed: the engine genuinely is Firefox and browser.browserType().name() returns firefox, so a script that cares can ask. Importing normalbrowser/chromium explicitly is a different thing and does throw.
webkit throwsThere is no WebKit counterpart to alias it to. It throws on first use with an explanation, rather than being exported as undefined and failing later at some unrelated line.
Mobile device descriptors throwA desktop engine reporting a phone user agent is a contradiction any detector checks for. Failing is more useful than a session that is caught.
evaluate() also accepts a stringPlaywright requires a function. This client accepts either, which is convenient, but it means a snippet written against the Firefox backend can fail silently on the Chromium one, where a string is evaluated as an expression and returns the function rather than calling it.
One context per processContexts in one process share a fingerprint. Separate processes are what make identities genuinely separate, see the linkability measurement.
Interception disables the disk cacheA cached response never reaches the proxy, so it could not be intercepted. This costs bandwidth, which is why interception is opt-in.

Something missing?

The client's README covers every API surface in detail, and the repository is where corrections belong.

npx normalbrowser --help