How to Run a Headless Browser in JavaScript
Launch a real browser without a visible window using Playwright or Puppeteer, with runnable scripts, CI setup, troubleshooting, and screenshots.
A headless browser is a real browser engine that runs without opening a visible window. In JavaScript, the dependable sequence is: install an automation library and its compatible browser, launch it, create a page, navigate or interact, collect output such as text or a screenshot, and close the browser in a finally block.
For most new projects, start with Playwright when you need Chromium, Firefox, and WebKit coverage or explicit browser-binary management. Choose Puppeteer for a straightforward Chrome-centered API. Both launch headlessly by default.
1. Choose Playwright or Puppeteer
| Decision | Playwright | Puppeteer |
|---|---|---|
| Browser coverage | Chromium, Firefox, and WebKit | High-level control of Chrome or Firefox |
| Browser binaries | Install matching builds with the Playwright CLI | puppeteer normally downloads compatible Chrome; puppeteer-core does not |
| Best fit | Cross-engine tests, rendering, and controlled browser versions | Chrome-focused automation or an already managed browser |
| Headless modes | Default headless shell, or newer Chromium headless through a channel | Default headless mode or the documented shell mode |
Do not assume one library is universally faster. Browser engine, page content, network conditions, wait strategy, and CI hardware usually matter more than the package name. Test the exact browser and mode that you will run in production.
2. Install Playwright
For a library script, install the package and browser binaries:
npm init -y
npm install playwright
npx playwright install
The official starter command npm init playwright@latest creates a test project. For a single engine, install only what you need:
npx playwright install chromium
npx playwright install firefox
npx playwright install webkit
On Linux CI, install browser dependencies with:
npx playwright install --with-deps chromium
Playwright browser builds are coupled to Playwright releases. After upgrading the package, rerun the browser installer when the executable is missing or the versions no longer match. If you only need the headless shell, the browser docs also describe --only-shell; if you use the newer Chromium headless mode exclusively, --no-shell avoids downloading the separate shell. See Playwright browser installation and headless modes.
3. Run your first headless JavaScript script
This complete CommonJS example launches WebKit, opens a page, saves a screenshot, and always closes the browser:
const { webkit } = require('playwright');
(async () => {
const browser = await webkit.launch();
try {
const page = await browser.newPage();
await page.goto('https://playwright.dev/', { waitUntil: 'domcontentloaded' });
await page.screenshot({ path: 'example.png', fullPage: true });
console.log(await page.title());
} finally {
await browser.close();
}
})();
Playwright’s documented library flow uses launch, page creation, navigation, screenshot, and close; browsers run headlessly by default. The try/finally wrapper prevents a failed navigation from leaving a browser process behind. The same pattern works with Chromium or Firefox:
const { chromium, firefox } = require('playwright');
const browser = await chromium.launch();
// const browser = await firefox.launch();
For an ES module, use import { chromium } from 'playwright'; and put the lifecycle code in an async function. Set headless: false temporarily when diagnosing selectors or navigation; this opens a visible browser and is not required in production.
4. Navigate, wait, interact, and extract data
Navigation and waiting
await page.goto('https://example.com', {
waitUntil: 'domcontentloaded',
timeout: 30_000
});
await page.waitForSelector('main', { state: 'visible', timeout: 15_000 });
await page.waitForTimeout(500);
Use domcontentloaded when you need the document quickly, load when subresources should be loaded, and a selector or application-specific readiness signal when JavaScript renders the final content. Fixed delays are a last resort because they either waste time or remain too short for a slow page.
Click, fill, and read
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByLabel('Email').fill('dev@example.com');
await page.getByLabel('Password').fill(process.env.DEMO_PASSWORD);
await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForURL('**/dashboard');
const heading = await page.locator('h1').textContent();
console.log(heading);
Prefer role, label, and stable test identifiers over brittle CSS paths. Treat credentials as secrets and load them from environment variables.
Capture a specific viewport or element
await page.setViewportSize({ width: 1440, height: 900 });
await page.screenshot({ path: 'viewport.png' });
await page.locator('.invoice').screenshot({ path: 'invoice.png' });
For full-page output, use fullPage: true. For a crisp image on a high-density display, create the context with a larger device scale factor:
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
deviceScaleFactor: 2,
colorScheme: 'dark',
locale: 'en-US',
timezoneId: 'America/New_York'
});
const page = await context.newPage();
Collect page text or HTML
const text = await page.locator('body').innerText();
const html = await page.content();
require('node:fs').writeFileSync('page.txt', text);
Block or modify network requests
await page.route('**/*.{png,jpg,jpeg,gif}', route => route.abort());
await page.goto('https://example.com');
Request interception is useful for deterministic tests, but blocking fonts, scripts, or APIs can change the page you are trying to measure. Keep the production route policy close to the real user path.
5. The equivalent Puppeteer setup
Install managed Chrome with:
npm install puppeteer
The package normally downloads a compatible Chrome during installation. If your package manager blocks install scripts, run npx puppeteer browsers install or allow the Puppeteer install script. Use puppeteer-core when a browser is provisioned separately; provide its executable path or a remote connection yourself.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.screenshot({ path: 'example.png', fullPage: true });
console.log(await page.title());
} finally {
await browser.close();
}
Puppeteer is headless by default. Its documented headless modes include the regular mode and headless: 'shell'. Shell mode can be more performant when you do not need the full browser feature set, but it does not completely match regular Chrome; verify behavior when fidelity matters. See the Puppeteer headless mode guide.
6. Production configuration and edge cases
- Authentication: create a context with
httpCredentials, set cookies before navigation, or add anAuthorizationheader through request interception. Never hard-code tokens. - Downloads: wait for the download event and save the returned path rather than assuming a fixed filename.
- iframes: select the matching frame before locating elements inside it.
- popups and new tabs: await the popup event at the same time as the click so the event cannot be missed.
- dialogs: register a dialog handler before the action that triggers it.
- infinite scroll: scroll in bounded steps and stop when the document height stops changing; add a maximum item or time limit.
- lazy images: wait for the image selector and confirm
img.completebefore capturing. - cookie banners: dismiss them with a stable selector or an accessibility role before taking a screenshot.
- PDF output: use a Chromium page and configure paper format, margins, header/footer, and print background according to your document requirements.
- parallelism: reuse one browser process, create isolated contexts, and cap concurrent pages so CPU and memory remain predictable.
7. CI, containers, and reliability
Pin the Node.js, automation-library, and browser versions in your lockfile and CI image. Run the browser installer during image creation so a job does not depend on a download at runtime. On Linux, use Playwright’s --with-deps option when the image lacks required shared libraries.
Set explicit navigation and assertion timeouts, log the final URL and browser version, and save a screenshot, trace, or HTML snapshot when a job fails. Close pages, contexts, and the browser even on errors. A hanging Node process is often an unclosed browser or a server left listening for connections.
Headless implementations can differ: Playwright’s default shell and newer Chromium headless channel are distinct, and Puppeteer’s shell mode is not identical to regular Chrome. If CI output differs from a laptop, compare the exact browser build, mode, viewport, fonts, timezone, locale, and environment variables before changing selectors.
8. Troubleshooting common errors
| Error or symptom | Likely cause | Fix |
|---|---|---|
| Executable doesn’t exist | Browser binaries were not installed or are out of sync | Run npx playwright install, or for Puppeteer run npx puppeteer browsers install; repeat after upgrades |
| Missing shared library on Linux | Container or host lacks browser dependencies | Run npx playwright install --with-deps chromium or use an image with the required libraries |
| Navigation timeout | Slow server, blocked request, redirect loop, or waiting for the wrong lifecycle event | Log the URL, check network access, choose an appropriate waitUntil, and set a justified timeout; do not hide a persistent outage with an unlimited timeout |
| Selector timeout | Wrong selector, iframe, delayed rendering, or consent dialog covering the target | Inspect the DOM in headed mode, select the correct frame, wait for an application-specific signal, and handle the dialog |
| Different screenshot in CI | Different browser mode, fonts, viewport, timezone, or device scale | Pin versions and context settings, then compare the rendered DOM and computed styles |
| Process never exits | Browser, context, page, or application server remains open | Close resources in finally; ensure test servers and event listeners are shut down |
| Puppeteer installed without Chrome | Package-manager install scripts were disabled | Permit the script or run npx puppeteer browsers install; use puppeteer-core only with your own browser provisioning |
9. Performance, reliability, and cost
Launching a browser is expensive compared with opening a normal HTTP client. For batches, launch once, reuse the process, and create a fresh context per isolated job. Reuse pages only when state isolation is not required. Block genuinely unnecessary resources, but measure the effect because blocking scripts or styles can alter layout and behavior.
Use bounded concurrency, explicit timeouts, and retries only for transient failures. A retry should create a clean context and record the original error. Cache stable assets or results at the application layer when the page is unchanged. Avoid taking full-page screenshots when a component screenshot is enough.
Self-hosted automation costs include Node.js compute, browser downloads, container storage, bandwidth, and engineering time for fonts, dependencies, retries, and anti-bot or consent flows. There is no controlled benchmark in the available sources, so choose a mode based on required fidelity and validate it in your own workload.
10. Or skip the browser setup
If your goal is a clean website screenshot rather than browser automation itself, ScreenshotNeo provides a GET API and an MCP server. It accepts consent banners like a visitor, removes more than 60 known consent platforms plus newsletter popups and chat widgets before capture, and lets you turn each step off. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed; each response identifies the result with X-Page-Verdict and X-Billed headers.
See the ScreenshotNeo API documentation for all options. A one-call capture looks like this:
curl -G 'https://api.screenshotneo.com/v1/shot' -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get('https://api.screenshotneo.com/v1/shot', params={'access_key': 'YOUR_API_KEY', 'url': 'https://stripe.com'}, timeout=90)
r.raise_for_status()
open('shot.webp', 'wb').write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
require('node:fs').writeFileSync('shot.webp', Buffer.from(await res.arrayBuffer()));
ScreenshotNeo also offers full-page capture with lazy images loaded, CSS-selector element capture, dark mode, device presets and custom viewports, retina scale, PDF output, custom CSS and JavaScript, clicks and waits, ad/tracker/request blocking, custom headers and cookies, timezone and geolocation, transparent backgrounds, resizing, configurable caching, signed links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API, and an OpenAPI specification. An MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.
The Free plan includes 1,000 shots per month with no card. Paid plans start at $5 for 3,000 shots; yearly billing gives two months free, and every feature is on every plan. Create a free ScreenshotNeo account and get 1,000 screenshots a month with no card.
11. FAQ
Does headless mean the page is not a real browser?
No. Playwright and Puppeteer drive browser engines; headless changes how the browser is displayed and, depending on the selected mode, can change implementation details.
Which library should a new JavaScript project use?
Use Playwright when you need multiple browser engines or managed browser versions. Use Puppeteer when a Chrome-focused workflow and its package-managed browser fit better.
Can I run headless browsers in serverless functions?
Often, but the function must include a compatible browser binary, shared libraries, writable temporary storage, and enough memory and execution time. Check the limits of your provider and test cold starts.
Why does my screenshot miss content below the fold?
A normal viewport screenshot captures only the viewport. Use full-page capture, scroll lazy content into view, and wait for the page’s loading signal before saving the image.
Should I use a fixed sleep after every action?
No. Prefer locator, URL, network, or application-state waits. Keep short fixed delays only for a known animation or a third-party widget that has no reliable readiness signal.


