Headless mode allows you to run browser automation without displaying a visible browser window. This is ideal for server environments, CI/CD pipelines, and background tasks.
Enabling headless mode
Basic headless setup
Simply pass headless=True to the start() function:
import nodriver as uc
# Start in headless mode
browser = await uc.start(headless=True)
tab = await browser.get('https://example.com')
print(f'Page loaded: {tab.url}')
browser.stop()
Using Config object
For more control, use a Config object:
from nodriver import Config
config = Config(
headless=True,
sandbox=True # Important for server environments
)
browser = await uc.start(config=config)
From config.py:37-81:
class Config:
def __init__(
self,
user_data_dir: Optional[PathLike] = AUTO,
headless: Optional[bool] = False,
browser_executable_path: Optional[PathLike] = AUTO,
browser_args: Optional[List[str]] = AUTO,
sandbox: Optional[bool] = True,
lang: Optional[str] = "en-US",
host: str = AUTO,
port: int = AUTO,
expert: bool = AUTO,
**kwargs: dict,
):
"""
Creates a config object.
Can be called without any arguments to generate a best-practice config.
:param headless: set to True for headless mode
:param sandbox: disables sandbox
"""
Headless mode features
Automatic user agent cleaning
Nodriver automatically removes “Headless” from the user agent:
browser = await uc.start(headless=True)
tab = browser.main_tab
# User agent is automatically cleaned
ua = await tab.evaluate('navigator.userAgent', return_by_value=True)
print(ua) # Won't contain "Headless"
From tab.py:203-222:
async def _prepare_headless(self):
if getattr(self, "_prep_headless_done", None):
return
resp = await self._send_oneshot(
cdp.runtime.evaluate(
expression="navigator.userAgent",
)
)
if not resp:
return
response, error = resp
if response and response.value:
ua = response.value
await self._send_oneshot(
cdp.network.set_user_agent_override(
user_agent=ua.replace("Headless", ""),
)
)
setattr(self, "_prep_headless_done", True)
All features work in headless
Headless mode supports all nodriver features:
browser = await uc.start(headless=True)
tab = await browser.get('https://example.com')
# Screenshots work
await tab.save_screenshot('headless.jpg')
# Element interaction works
button = await tab.select('button')
await button.click()
# JavaScript execution works
result = await tab.evaluate('document.title', return_by_value=True)
print(result)
# Network monitoring works
import nodriver.cdp as cdp
tab.add_handler(cdp.network.ResponseReceived, lambda e: print(e.response.url))
Chrome arguments for headless
The headless mode uses Chrome’s new headless implementation:
From config.py:192-194:
if self.headless:
args.append("--headless=new")
Nodriver uses --headless=new instead of the old --headless flag. The new mode is more compatible and harder to detect.
Sandbox mode
Why sandbox matters
Sandbox mode is important for security, especially in production environments:
# With sandbox (recommended)
browser = await uc.start(
headless=True,
sandbox=True
)
# Without sandbox (only if needed)
browser = await uc.start(
headless=True,
sandbox=False # Disables Chrome sandbox
)
From config.py:99-108:
self.sandbox = sandbox
# When using posix-ish operating system and running as root
# you must use no_sandbox = True, which in case is corrected here
if is_posix and is_root() and sandbox:
logger.info("detected root usage, auto disabling sandbox mode")
self.sandbox = False
If you’re running as root on Linux, nodriver automatically disables sandbox mode. This is less secure but necessary for root execution.
Server/Docker environments
Running in Docker
Example Dockerfile for headless nodriver:
FROM python:3.11-slim
# Install Chrome dependencies
RUN apt-get update && apt-get install -y \
wget \
gnupg \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
xdg-utils \
--no-install-recommends
# Install Chrome
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
&& echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list \
&& apt-get update \
&& apt-get install -y google-chrome-stable \
&& rm -rf /var/lib/apt/lists/*
# Install Python packages
RUN pip install nodriver
# Copy your script
COPY script.py /app/script.py
WORKDIR /app
# Run script
CMD ["python", "script.py"]
Python script for Docker:
# script.py
import nodriver as uc
async def main():
# Headless mode for Docker
browser = await uc.start(
headless=True,
sandbox=False # Usually needed in Docker
)
tab = await browser.get('https://example.com')
await tab.wait(2)
title = await tab.evaluate('document.title', return_by_value=True)
print(f'Page title: {title}')
browser.stop()
if __name__ == '__main__':
uc.loop().run_until_complete(main())
Running on Linux servers
For headless Linux servers, you may need additional dependencies:
# Ubuntu/Debian
sudo apt-get update
sudo apt-get install -y \
chromium-browser \
chromium-chromedriver \
xvfb
# Install nodriver
pip install nodriver
Python script:
import nodriver as uc
async def main():
browser = await uc.start(
headless=True,
browser_executable_path='/usr/bin/chromium-browser'
)
# Your automation here
tab = await browser.get('https://example.com')
# ...
browser.stop()
Debugging headless mode
Remote debugging
Connect to a headless browser using the inspector:
browser = await uc.start(
headless=True,
host='0.0.0.0', # Allow remote connections
port=9222
)
tab = browser.main_tab
print(f'Debug URL: {tab.inspector_url}')
# Now connect to this URL from another browser
await tab.get('https://example.com')
await tab.wait(60) # Keep alive for debugging
Then open the inspector URL in another browser to see what’s happening.
Opening inspector programmatically
From tab.py:189-196:
async def open_external_inspector(self):
"""
Opens the system's browser containing the devtools inspector page
for this tab. Could be handy, especially to debug in headless mode.
"""
import webbrowser
webbrowser.open(self.inspector_url)
Usage:
browser = await uc.start(headless=True)
tab = browser.main_tab
# Open inspector in your default browser
await tab.open_external_inspector()
# Continue automation
await tab.get('https://example.com')
await tab.wait(60)
Taking screenshots for debugging
browser = await uc.start(headless=True)
tab = await browser.get('https://example.com')
# Take screenshot at each step
await tab.save_screenshot('step1.jpg')
button = await tab.select('button')
await button.click()
await tab.save_screenshot('step2.jpg')
# Compare screenshots to debug issues
Headless is faster
Headless mode is generally faster than headed mode:
import time
start = time.time()
browser = await uc.start(headless=True)
tab = await browser.get('https://example.com')
await tab.wait(2)
browser.stop()
print(f'Time taken: {time.time() - start:.2f}s')
Memory usage
Headless mode uses less memory without GPU rendering:
# Optimize for low memory
config = Config(
headless=True,
browser_args=[
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-software-rasterizer',
'--no-sandbox'
]
)
browser = await uc.start(config=config)
Resource limits
Set resource limits for long-running processes:
config = Config(
headless=True,
browser_args=[
'--disable-gpu',
'--disable-dev-shm-usage',
'--single-process', # Use single process (saves memory)
'--no-zygote' # Don't use zygote process
]
)
Using --single-process can make the browser less stable. Only use it if you’re having memory issues.
Real-world example: Scraping service
import nodriver as uc
from pathlib import Path
import asyncio
import json
class HeadlessScraperService:
def __init__(self):
self.browser = None
self.results_dir = Path('results')
self.results_dir.mkdir(exist_ok=True)
async def start(self):
"""Initialize headless browser"""
self.browser = await uc.start(
headless=True,
sandbox=True
)
print('Scraper service started')
async def scrape_page(self, url: str) -> dict:
"""Scrape a single page"""
tab = await self.browser.get(url)
await tab.wait(2)
# Extract data
title = await tab.evaluate('document.title', return_by_value=True)
html = await tab.get_content()
# Take screenshot
screenshot_path = self.results_dir / f'{url.split("/")[-1]}.jpg'
await tab.save_screenshot(str(screenshot_path))
# Close tab to free memory
await tab.close()
return {
'url': url,
'title': title,
'screenshot': str(screenshot_path),
'html_length': len(html)
}
async def scrape_multiple(self, urls: list) -> list:
"""Scrape multiple pages"""
results = []
for url in urls:
try:
result = await self.scrape_page(url)
results.append(result)
print(f'Scraped: {url}')
except Exception as e:
print(f'Error scraping {url}: {e}')
results.append({'url': url, 'error': str(e)})
# Small delay between requests
await asyncio.sleep(1)
return results
async def stop(self):
"""Cleanup"""
if self.browser:
self.browser.stop()
print('Scraper service stopped')
async def main():
service = HeadlessScraperService()
await service.start()
urls = [
'https://example.com',
'https://example.org',
'https://example.net'
]
results = await service.scrape_multiple(urls)
# Save results
with open('results/scrape_results.json', 'w') as f:
json.dump(results, f, indent=2)
print(f'Scraped {len(results)} pages')
await service.stop()
if __name__ == '__main__':
uc.loop().run_until_complete(main())
CI/CD integration
GitHub Actions example
name: Scraper Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.11'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y chromium-browser
pip install nodriver pytest
- name: Run tests
run: |
pytest tests/ -v
Test file:
# tests/test_scraper.py
import pytest
import nodriver as uc
@pytest.mark.asyncio
async def test_headless_scraping():
browser = await uc.start(headless=True, sandbox=False)
tab = await browser.get('https://example.com')
title = await tab.evaluate('document.title', return_by_value=True)
assert 'Example' in title
browser.stop()
Best practices
Always use headless in production
Headless mode is more stable and uses fewer resources.
Disable sandbox only when necessary
Keep sandbox enabled unless you’re running as root or in Docker.
Always stop the browser when done:
try:
browser = await uc.start(headless=True)
# Your automation
finally:
browser.stop()
Use remote debugging for troubleshooting
When issues occur, enable remote debugging to see what’s happening.
For long-running services, monitor and restart periodically:
import psutil
import os
process = psutil.Process(os.getpid())
print(f'Memory usage: {process.memory_info().rss / 1024 / 1024:.2f} MB')