Skip to main content

A headless browser runs without a window. It still loads pages, runs scripts and takes screenshots and PDFs, but it draws nothing on a screen. A headed browser opens a window you can watch. This page covers the default of each engine, how to switch between the two modes, and what headless mode changes.

Info

What you will learn

  • What each engine does by default (BaseCase, pure CDP, Playwright mode, sbrs, sbrs-mcp).
  • How to switch in code, on the command line, with SB_HEADLESS and in the settings file.
  • How Chrome’s new headless mode differs from chrome-headless-shell.
  • What gives a headless browser away, and what the stealth layer changes.
  • How to run in containers and CI, and how to debug a run you cannot see.
Quick reference
  • In code: BrowserConfig::with_headless(bool)
  • Pure CDP: LaunchOptionsBuilder::headless(bool)
  • Command line: sbrs --headless or sbrs --headed
  • Variable: SB_HEADLESS=true
  • Settings file: headless = true
  • Old headless mode: --browser chs
  • Headed on a Linux server: with_virtual_display

What headless mode does#

Each engine turns headless mode into a browser flag:

Browser and engineHeadless flagsWindow size
Chrome, Edge and the other Chromium browsers, WebDriver engine--headless=new and --disable-gpu--window-size=1280,720 (390,844 with mobile) unless you set one, headless or headed
Chrome, pure CDP engine--headless=newnone unless you set window_size(width, height) or a fingerprint gives a screen size
Firefox, WebDriver engine-headlessthe browser’s own
Chromium, Playwright moderustwright’s headless launch option1920 by 1080, maximised, unless you set one
Safarinone: Safari always opens a windowthe browser’s own
Use headless mode

Use it when nobody watches the run:

  • CI and containers. A server or container usually has no display, and a headed browser needs one.
  • Screenshots and PDFs. These work without a window. The real-Chrome test suite takes a PNG, a PDF and an element screenshot from a headless Chrome.
  • Many browsers at once. No windows open on your desktop.
Run headed

Run headed when someone has to see or use the window:

  • Watching a test. Demo mode (RunSettings::with_demo_mode) outlines each element and pauses after each action so you can follow along (see Run settings). with_esc_end(true) lets the person watching press ESC in the browser to end the test.
  • Recording. sbrs record saves the clicks and typing you do in the browser. Like the other sbrs browser commands, it is headed by default.
  • Clicking with the system pointer. Page::gui_click_captcha and Page::gui_captcha_plan (feature gui) refuse a headless browser, because it has no window on the screen to click.
  • Plugins that need a window. A browser plugin that declares Requirements::HEADFUL refuses a headless launch. sbrs plugin info NAME shows that as “a window, not headless”.
  • Sites that block headless browsers. See Headless and stealth.

In a headless browser, post_message and the other message helpers keep a message on the page for at most 0.75 s, as Python SeleniumBase does, because nobody is watching.

Defaults#

Each engine has its own default:

Entry pointDefaultHow to change it
BrowserConfig::default() and BrowserConfig::from_env(), which BaseCase::new and run_browser_test takeHeadlesswith_headless(bool). Neither constructor reads SB_HEADLESS.
Settings: sbase_config.toml, Settings::load() and to_browser_config(), also used by the helpers sbrs mkdir writesHeaded (headless = false)The file’s headless key, then SB_HEADLESS
sbrs browser commands (open, smoke, record, …)Headed--headless or --headed, SB_HEADLESS, the settings file
sbrs devtoolsBy system Headless on Linux, headed elsewhere--headless, or the global --headed
Pure CDP engine (LaunchOptions)By system Headless on Linux, headed on every other systemLaunchOptionsBuilder::headless(bool)
PlaywrightSession::launch()HeadedPlaywrightSession::launch_with_config(&config) uses config.headless; BaseCase::activate_playwright_mode uses the case’s configuration
sbrs-mcp start_browser (all three toolsets: sb, driver and cdp)By system Headless on Linux, headed elsewhereThe tool’s headless argument
SafariHeaded Always a windowSafari ignores the setting
Warning

BrowserConfig::default() is headless, but Settings::default() is headed. A test that builds BrowserConfig::default() runs without a window. The same test built from Settings::load()?.to_browser_config() opens one when there is no settings file and no SB_HEADLESS.

For Settings and the sbrs commands, each step in this chain overrides the one before it, and to_browser_config() turns the result into a BrowserConfig. A BrowserConfig you build in code with with_headless is final.

Switch between headless and headed#

with_headless(true) runs without a window (the default), and with_headless(false) opens one. A value you set in code is final: no variable or file changes it later.

tests/headed.rs

New headless mode and chrome-headless-shell#

Setting headless on Chrome launches Chrome’s new headless mode (--headless=new): your installed Chrome with no window. This is what Python SeleniumBase’s headless2 asks for. Chrome’s old headless mode now exists only as a separate build, chrome-headless-shell, which the WebDriver engine runs as Browser::ChromeHeadlessShell:

In the settings file and in SB_BROWSER, the name chrome-headless-shell works as well as chs.

Chrome with headlesschrome-headless-shell
What runsThe Chrome installed on the machineA separate build in downloaded_drivers/chs/, downloaded (stable channel) when missing
Driverchromedriver for your Chromechromedriver for the shell’s own version
UC modeYesNo: InvalidConfig “chrome-headless-shell cannot run the undetected (UC) mode”
Pure CDP engineheadless(true)Name the program with executable_path, or use_chromium(true) finds chrome-headless-shell on the PATH after chromium and chromium-browser
Python optionheadless, headless2headless1

Headless and stealth#

Pages can tell a headless Chrome from a headed one. The code and tests of this crate deal with these signs:

  • The user agent. A headless Chrome reports HeadlessChrome instead of Chrome. The pure CDP engine itself uses that to tell whether a browser it attaches to is headless.
  • No GPU. The WebDriver engine passes --disable-gpu only to a headless browser. A browser without a GPU reports a software renderer that pages can tell apart from a real one.
  • Permissions. navigator.permissions.query and Notification.permission give different answers for notifications.
  • Media queries and visibility. The answers of matchMedia, and document.hidden and visibilityState.
  • The hairline check that Modernizr runs.

The stealth layer changes these:

LayerWhat it does about headless
UC mode (DriverMode::Uc)If you set no user agent and Chrome reports HeadlessChrome, it overrides the user agent to say Chrome over CDP.
A Fingerprint whose policy has headless_fixes on (StealthPolicy::balanced(), which every preset uses)Runs the headless, permissions and hairline evasions and turns on Emulation.setFocusEmulationEnabled. matchMedia then reports no reduced motion, no dark color scheme and a fine pointer, document.hidden is false, visibilityState is visible, and the two permission answers agree. The outer window size is left to the window_geometry evasion.
A user agent from with_user_agent, LaunchOptionsBuilder::user_agent or a fingerprintReplaces the HeadlessChrome user agent at launch.
native_spoofing on a WebDriver session (Chrome, Chromium, Edge)Runs a patched copy of Chrome (EnginePatch::chrome_binary()), which also overwrites the HeadlessChrome marker in the binary.
Pure CDP engine without a user agent or fingerprintChanges nothing: the user agent still says HeadlessChrome.
Note

None of this makes a headless browser identical to a headed one. The network audit test (tests/stealth_audit_network.rs) only requires that, on CreepJS, a browser with a fingerprint looks no more headless than the same Chrome without one. On its first run (headless Chrome 155 on macOS), CreepJS still found two signs.

Tip

Recommendation. If a site still blocks a headless browser after these measures, run headed with with_headless(false) (or sbrs --headed). On a server with no display, give the headed browser a virtual display; see the last step below. UC mode lists the other measures.

Containers and CI#

Turn headless mode on

Set SB_HEADLESS=true for sbrs and for tests that load Settings. Keep BrowserConfig::default() (already headless) or call with_headless(true) in code. The pure CDP engine is headless on Linux unless you set headless(false). The repository’s verification image sets SB_HEADLESS=true because the container has no display.

Let Chrome start without its sandbox

On Linux, the WebDriver engine passes --no-sandbox to Chromium browsers itself. The pure CDP engine passes it only with LaunchOptionsBuilder::no_sandbox(true) or when SB_NO_SANDBOX is set, which Chrome needs when it runs as root in a container. The runtime image built from the Dockerfile runs as the non-root user sbrs.

Warning

--no-sandbox weakens Chrome’s isolation, so use it only in a container you control.

Give the browser shared memory

WebDriver sessions pass --disable-dev-shm-usage. The repository’s own container commands also start Docker with --shm-size=1g:

Terminal

The image’s entry point is sbrs, so the arguments after the image name are sbrs arguments.

Give a test that needs a window a virtual display

Linux onlyWebDriver engine

On Linux, BrowserConfig::with_virtual_display starts Xvfb and opens the headed browser on it. This is Python SeleniumBase’s --xvfb. The screen is 1366 by 768 unless you pass another VirtualDisplay:

tests/headed_on_a_server.rs
  • Xvfb must be installed (apt install xvfb on Debian or Ubuntu). It takes the first free display from :99 to :199 and stops when the session ends.
  • A session ignores the virtual display, and logs that, if it is headless, if it is not on Linux, or if it connects to a driver this crate did not start.
  • The pure CDP engine has no such option. Start Xvfb in the background and export DISPLAY before you launch the browser, as docker/verify-linux.sh does with SBRS_XVFB=1. That script does not use xvfb-run, which can wait forever for a signal that a background process ignores.

Doing things headless#

A headless browser does what a headed one does, except draw on a screen, so the recipes below run the same in both modes unless they say otherwise. Each recipe has a tab for every entry point that can do the task. In the BaseCase (WebDriver) tabs, sb is the &mut BaseCase that run_browser_test passes to the test, as in Switch between headless and headed. In the Pure CDP tabs, page comes from browser.default_page().await?. The command lines put the global --headless before the subcommand.

Note

Some BaseCase helpers speak the DevTools Protocol: print_to_pdf, save_as_pdf_to_logs, set_timezone, set_geolocation, cdp_click, cdp_type and solve_captcha. They need a Chromium session started with with_mode(DriverMode::Cdp) (or DriverMode::Uc). In plain WebDriver mode they fail with “CDP is unavailable for this session”.

Set the window and viewport size#

A headless browser has no screen to fill, so the page is laid out, and screenshots are taken, at the size the browser starts with or is given.

A Chromium session starts at 1280 by 720 pixels (390 by 844 with with_mobile(true)) unless with_window_size sets another size. set_window_size resizes a running browser, and reset_window_size goes back to 1280 by 720.

Window size

Take a screenshot#

Neither engine has a full-page capture: a page screenshot holds what fits in the window, so make the window taller first when you need more of the page.

save_screenshot and save_element_as_image_file write into the logs folder (latest_logs/) and take a plain file name. save_screenshot_to_path writes to the path you give, and screenshot_as_png returns the bytes.

Screenshots

sbrs screenshot and sbrs save-source start a new browser and capture it before any page is open, so they do not help with a page. Use one of the code tabs.

Printing needs no window. Both engines ask for the page’s backgrounds, and BaseCase also uses the page’s own CSS page size. To read the text back, see PDF parsing.

Printing goes over the DevTools Protocol, so start the session in DriverMode::Cdp. print_to_pdf writes to a path, and save_as_pdf_to_logs writes into latest_logs/ and returns the path.

PDF

Download a file and wait for it#

download_file opens the address in a new tab, waits up to the given number of seconds for the file, and goes back to the first tab. The assertions wait up to the default timeout for a file that is still being written. All of them look in get_downloads_folder(): the system’s Downloads folder, or the working directory when the system names none.

Downloads
Warning

The WebDriver engine does not tell the browser where to save. If the browser saves somewhere other than get_downloads_folder(), the wait ends in a timeout. The pure CDP engine sets the folder itself.

Upload a file#

Neither engine opens a file chooser, so uploads work headless: both put the path straight into the <input type=file> element.

choose_file waits up to 10 seconds for the input, and the file must exist on the machine that runs the test. A relative path is read from the working directory.

Upload

Handle alerts, confirms and prompts#

The alert methods work the same headless. Read the text before you answer; dismiss_alert is the Cancel button.

Dialogs

Run JavaScript and read the result#

execute_script runs the script as the body of a function, so return the value you want. It comes back as a serde_json::Value.

JavaScript

Record a HAR#

The WebDriver engine has no HAR recorder. Use the pure CDP engine.

Read the page source and the console#

get_page_source returns the HTML, and save_page_source_to_logs saves it in latest_logs/. start_recording_console_logs wraps console.log in the current document, so start it after the page loads and read the lines before it navigates. assert_no_js_errors reads the browser’s own log, which only Chromium browsers keep, and fails on its SEVERE entries.

Source and console

Emulate a device, user agent, location or time zone#

with_device_metrics turns on Chrome’s phone emulation: the page sees that viewport, a touch screen and that pixel ratio, with an Android user agent unless with_user_agent gives one. with_mobile(true) alone only gives a 390 by 844 window and the Android user agent. with_locale sets the language. set_timezone and set_geolocation change a running browser and need CDP mode. BaseCase has no method that grants the location permission.

Emulation

What does not work headless#

These need a window on a screen, or a person in front of it:

WhatWhyInstead
gui_click_x_y, gui_write, gui_click_element and the other gui_* helpers, and their uc_gui_* aliases (feature gui)They move the operating system’s own mouse and keyboard, and a headless browser has no window for them to reachClick and type over DevTools: cdp_click and cdp_type on BaseCase in CDP mode, Locator::click and type_text in the pure CDP engine. Or run headed on a virtual display.
Page::gui_click_captcha and gui_captcha_plan (feature gui)They refuse a headless browser: “a headless browser has no window on the screen to click”solve_captcha, which clicks over DevTools: on Page, or on BaseCase in CDP mode
Demo mode, slow mode and the messages of post_messageThey still run, but nobody sees them, so they only slow the run; a message stays at most 0.75 sScreenshots, or a headed run
with_esc_end(true)It ends the test when a person presses ESC in the windowA headed run
Native dialogs (api::dialog, choose_file_dialog)They open the desktop’s own dialogs and wait for a personLeave them out of unattended runs
sbrs recordIt saves the clicks and typing a person does in the windowA headed run
Browser plugins with Requirements::HEADFULThey refuse a headless launchA headed run, or no such plugin

On a Linux server, run the headed browser on a virtual display with BrowserConfig::with_virtual_display (see Containers and CI), and see GUI automation for the gui feature.

Debugging a headless run#

  • Save what the browser shows. sb.save_screenshot("step.png") saves a screenshot of the viewport in the logs directory (the name must be a plain file name), and save_page_source_to_logs() and save_as_pdf_to_logs(name) save the HTML and a PDF. In the pure CDP engine, page.screenshot() and page.pdf() return the bytes.
  • Check the mode in the log. BaseCase::new and the session log a headless field at the info level, which init_tracing() shows unless RUST_LOG says otherwise.
  • Check what the page sees. sb.get_user_agent() returns a user agent with HeadlessChrome in it when nothing replaced it.
  • Attach DevTools. BrowserConfig::with_remote_debug(true) opens Chrome’s remote debugger at http://localhost:9222, and with_remote_debug_port(port) opens it on another port. In the pure CDP engine, use LaunchOptionsBuilder::debugging_port(9222). sbrs devtools --headless --port 0 --json launches a headless Chrome and prints its DevTools endpoint. Anyone on the machine who can reach the port can drive the browser, so use it for debugging only.
  • Watch it. Run the same test headed (--headed, SB_HEADLESS=false or with_headless(false)), with demo mode if the steps are too fast to follow.
  • Do not compare timings across modes. Real Chrome behaves differently in headless and headed runs, so check which mode a test uses before you trust a timing. With reuse_session, a headless browser is never handed to a headed configuration, or the reverse: the mode is part of what a reused browser must match.

Troubleshooting#

SB_HEADLESS=true has no effect

Only Settings::load(), Settings::load_from, Settings::from_env() and the sbrs browser commands read the variable. A program that builds BrowserConfig::default() or BrowserConfig::from_env() is headless anyway. A program that sets with_headless(false) stays headed. The pure CDP engine and sbrs-mcp ignore the variable. On the command line, --headed beats it. Build the configuration with Settings::load()?.to_browser_config(), and export the variable (or put it before the command on the same line).

A window opens in CI, or Chrome fails because there is no display

The settings file, SB_HEADLESS and sbrs are headed by default, and a headed browser needs a display. Set SB_HEADLESS=true, pass --headless before the subcommand, or give the browser a virtual display.

sbrs refuses --headless=false

--headless and --headed take no value. Pass --headed to open a window, and write global options before the subcommand: sbrs --headless open URL.

cannot parse bool: maybe

SB_HEADLESS has a value that is not one of true, false, 1, 0, yes, no, on or off. sbrs stops before it starts a browser. In the settings file, headless must be a boolean (true or false), or the file does not load.

chrome-headless-shell cannot run the undetected (UC) mode

Browser::ChromeHeadlessShell cannot run with DriverMode::Uc. Use Chrome with with_headless(true), which UC mode supports, or drop UC mode.

chrome-headless-shell is not installed

The session looks for it under downloaded_drivers/chs/ and downloads the stable build there when it is missing. If the download fails, put the program there, or name it with BrowserConfig::with_browser_binary_path.

Xvfb is not installed or not on the PATH

with_virtual_display needs the Xvfb program. Install it (apt install xvfb) or run headless. If no virtual display starts, check the log: the session skips it for a headless browser, outside Linux, and with a driver it did not start itself.

a headless browser has no window on the screen to click

Page::gui_click_captcha moves the system pointer, so it needs a window on the screen. Launch the browser headed, or use solve_captcha, which clicks over DevTools.

A plugin refuses the browser

A plugin with Requirements::HEADFUL refuses a headless launch with a SeleniumBaseError::Plugin error. sbrs plugin info NAME lists what a plugin needs. Run headed, or drop that plugin. See Browser plugins.

The site detects the headless browser

See Headless and stealth: use UC mode or a fingerprint first, then run headed (on a virtual display on a server).

Coming from Python SeleniumBase#

Python optionRust
headlessBrowserConfig::with_headless(true)
headedBrowserConfig::with_headless(false)
headless2with_headless(true): Chrome’s new headless mode (--headless=new)
headless1with_browser(Browser::ChromeHeadlessShell) with headless on
--xvfbBrowserConfig::with_virtual_display
--xvfb-metrics=WIDTH,HEIGHTVirtualDisplay::new(width, height) (1366, 768 when not given)

Next steps#