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.
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_HEADLESSand 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.
- In code:
BrowserConfig::with_headless(bool) - Pure CDP:
LaunchOptionsBuilder::headless(bool) - Command line:
sbrs --headlessorsbrs --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 engine | Headless flags | Window 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=new | none unless you set window_size(width, height) or a fingerprint gives a screen size |
| Firefox, WebDriver engine | -headless | the browser’s own |
| Chromium, Playwright mode | rustwright’s headless launch option | 1920 by 1080, maximised, unless you set one |
| Safari | none: Safari always opens a window | the browser’s own |
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 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 recordsaves the clicks and typing you do in the browser. Like the othersbrsbrowser commands, it is headed by default. - Clicking with the system pointer.
Page::gui_click_captchaandPage::gui_captcha_plan(featuregui) 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::HEADFULrefuses a headless launch.sbrs plugin info NAMEshows 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 point | Default | How to change it |
|---|---|---|
BrowserConfig::default() and BrowserConfig::from_env(), which BaseCase::new and run_browser_test take | Headless | with_headless(bool). Neither constructor reads SB_HEADLESS. |
Settings: sbase_config.toml, Settings::load() and to_browser_config(), also used by the helpers sbrs mkdir writes | Headed (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 devtools | By system Headless on Linux, headed elsewhere | --headless, or the global --headed |
Pure CDP engine (LaunchOptions) | By system Headless on Linux, headed on every other system | LaunchOptionsBuilder::headless(bool) |
PlaywrightSession::launch() | Headed | PlaywrightSession::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 elsewhere | The tool’s headless argument |
| Safari | Headed Always a window | Safari ignores the setting |
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.
LaunchOptionsBuilder::headless(bool) sets the mode. Leave it unset to get the
default for the system: headless on Linux, where there is usually no display, and
headed elsewhere. LaunchOptions::is_headless() shows which mode the options will
launch.
--headless and --headed are global options, so they go before the subcommand:
- A flag beats
SB_HEADLESS, andSB_HEADLESSbeats the settings file. - If you pass both flags,
--headlesswins. - These flags take no value:
--headless=falseis refused. Use--headed. - A global option written after the subcommand is refused too.
sbrs testpasses the flag to the tests asSB_HEADLESS, so a test that loadsSettingssees it.sbrs devtoolsalso has its own--headlessoption, written afterdevtools.
SB_HEADLESS accepts true/false, 1/0, yes/no and on/off. A blank
value counts as unset. Any other value is an InvalidConfig error
(cannot parse bool: maybe), and sbrs stops before it starts a browser.
Default: "unset"
SB_HEADLESS is read by Settings::load(), Settings::load_from(path),
Settings::from_env() and the sbrs browser commands. It is not read by
BrowserConfig::from_env(), the pure CDP engine or sbrs-mcp. To make a
BaseCase follow it, build the configuration from Settings:
For the pure CDP engine, read it yourself. The programs that
sbrs import-playwright and sbrs import-puppeteer write do this:
Settings::load() and sbrs look for sbase_config.toml, then
.sbase_config.toml, then .sbase_config (JSON) in the current directory:
SB_HEADLESS overrides the file, and an sbrs flag overrides both. sbrs --config FILE reads another file instead. See Settings.
The start_browser tool of every sbrs-mcp toolset takes a headless argument:
true forces headless and false forces headed. Without it, the browser is
headless on Linux and headed elsewhere. The server does not read SB_HEADLESS.
See MCP servers.
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 headless | chrome-headless-shell | |
|---|---|---|
| What runs | The Chrome installed on the machine | A separate build in downloaded_drivers/chs/, downloaded (stable channel) when missing |
| Driver | chromedriver for your Chrome | chromedriver for the shell’s own version |
| UC mode | Yes | No: InvalidConfig “chrome-headless-shell cannot run the undetected (UC) mode” |
| Pure CDP engine | headless(true) | Name the program with executable_path, or use_chromium(true) finds chrome-headless-shell on the PATH after chromium and chromium-browser |
| Python option | headless, headless2 | headless1 |
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
HeadlessChromeinstead ofChrome. The pure CDP engine itself uses that to tell whether a browser it attaches to is headless. - No GPU. The WebDriver engine passes
--disable-gpuonly 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.queryandNotification.permissiongive different answers for notifications. - Media queries and visibility. The answers of
matchMedia, anddocument.hiddenandvisibilityState. - The hairline check that Modernizr runs.
The stealth layer changes these:
| Layer | What 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 fingerprint | Replaces 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 fingerprint | Changes nothing: the user agent still says HeadlessChrome. |
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 CreepJSA public bot-detection test page that grades how headless and how patched a browser looks., 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.
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.
--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:
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
On Linux, BrowserConfig::with_virtual_display starts XvfbA virtual X display that a headed browser opens its window on when the machine has no screen. and opens the headed
browser on it. This is Python SeleniumBase’s --xvfb. The screen is 1366 by 768
unless you pass another VirtualDisplay:
- Xvfb must be installed (
apt install xvfbon Debian or Ubuntu). It takes the first free display from:99to:199and 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
Xvfbin the background and exportDISPLAYbefore you launch the browser, asdocker/verify-linux.shdoes withSBRS_XVFB=1. That script does not usexvfb-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.
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.
Without window_size, the pure CDP engine passes no size and Chrome keeps its
own, unless a fingerprint gives a screen size. page.window() resizes and reads
a running window.
sbrs takes the size from SB_WINDOW_WIDTH and SB_WINDOW_HEIGHT, or from
window_width and window_height in the settings file. Set both, or neither.
--mobile gives a 390 by 844 window and an Android user agent.
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.
page.screenshot() captures the viewport as PNG bytes. A locator’s screenshot()
scrolls the element into view and captures its box at scale 1, also the part
that reaches past the window.
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.
Print a page to PDF#
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.
page.pdf() returns the PDF bytes.
sbrs print-pdf opens a URL or a local file and prints it (to page.pdf
without -o). On a Chromium browser it turns CDP mode on by itself.
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.
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.
download_file has the page fetch the address, with its cookies, and
download_by_click clicks a link the server answers with a download. Both tell
Chrome to save into the folder you give, create it, and wait, up to the page’s
timeout, for the browser to report the file finished. Until a folder is given,
assert_downloaded_file looks in downloaded_files/.
A file of the same name in the folder is replaced, and an address on another site needs CORS headers, because the page itself fetches it.
sbrs download-file downloads with the browser and, when that fails or takes
longer than --timeout seconds (30 by default), over plain HTTP.
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.
The pure CDP engine has no upload method. The programs that
sbrs import-playwright and sbrs import-puppeteer write add a helper that
focuses the input and hands it to DOM.setFileInputFiles with page.execute.
The same steps, with serde_json in your Cargo.toml:
sbrs choose-file opens --url (or a blank page with a file input) and puts
the file into the input the selector names.
Handle alerts, confirms and prompts#
The alert methods work the same headless. Read the text before you answer;
dismiss_alert is the Cancel button.
The pure CDP engine has no dialog methods. Replace the page’s dialog functions
before the step that would open one, so none opens. add_init_script covers
every page the tab loads from now on, and evaluate covers the page already
open.
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.
evaluate takes an expression, waits for a promise, and returns a
serde_json::Value. evaluate_as turns the result into a Rust type. A return
at the start of the last line is dropped, so a WebDriver-style script works too.
Record a HAR#
The WebDriver engine has no HAR recorder. Use the pure CDP engine.
start_har records the tab’s requests, and those of its iframes, from the
moment it returns, so start it before the navigation. Response bodies are left
out unless you ask for them with start_har_with.
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.
page.content() returns the HTML as scripts left it. js_errors lists the
uncaught errors and unhandled promise rejections of the current document. The
engine has no recorder for console.log.
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.
page.emulation() changes what one tab reports, and the location also needs
the permission. Replacing HeadlessChrome in the user agent hides the most
visible headless sign. The pure CDP engine has no phone emulation: set the
window size and user agent, or a fingerprint (see
Fingerprints and stealth).
For the language, LaunchOptionsBuilder::lang sets it at launch, and
emulation.locale from the next page load. LaunchOptionsBuilder::user_agent
sets the user agent at launch.
--mobile (a 390 by 844 window and an Android user agent), --locale and
--user-agent (or -a) are global options. The command line cannot set a time
zone or a location.
What does not work headless#
These need a window on a screen, or a person in front of it:
| What | Why | Instead |
|---|---|---|
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 reach | Click 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_message | They still run, but nobody sees them, so they only slow the run; a message stays at most 0.75 s | Screenshots, or a headed run |
with_esc_end(true) | It ends the test when a person presses ESC in the window | A headed run |
Native dialogs (api::dialog, choose_file_dialog) | They open the desktop’s own dialogs and wait for a person | Leave them out of unattended runs |
sbrs record | It saves the clicks and typing a person does in the window | A headed run |
Browser plugins with Requirements::HEADFUL | They refuse a headless launch | A 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), andsave_page_source_to_logs()andsave_as_pdf_to_logs(name)save the HTML and a PDF. In the pure CDP engine,page.screenshot()andpage.pdf()return the bytes. - Check the mode in the log.
BaseCase::newand the session log aheadlessfield at theinfolevel, whichinit_tracing()shows unlessRUST_LOGsays otherwise. - Check what the page sees.
sb.get_user_agent()returns a user agent withHeadlessChromein it when nothing replaced it. - Attach DevTools.
BrowserConfig::with_remote_debug(true)opens Chrome’s remote debugger athttp://localhost:9222, andwith_remote_debug_port(port)opens it on another port. In the pure CDP engine, useLaunchOptionsBuilder::debugging_port(9222).sbrs devtools --headless --port 0 --jsonlaunches 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=falseorwith_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 option | Rust |
|---|---|
headless | BrowserConfig::with_headless(true) |
headed | BrowserConfig::with_headless(false) |
headless2 | with_headless(true): Chrome’s new headless mode (--headless=new) |
headless1 | with_browser(Browser::ChromeHeadlessShell) with headless on |
--xvfb | BrowserConfig::with_virtual_display |
--xvfb-metrics=WIDTH,HEIGHT | VirtualDisplay::new(width, height) (1366, 768 when not given) |
Next steps#
Hide the automation markers, headless or headed.
Build the image and run sbrs headless in a container.
Drive Chrome without a driver, headless by default on Linux.
The settings file, the SB_* variables and their order.
The stealth policy and the headless fixes it turns on.
What to try when a site detects the headless browser.