· Updated · Tutorials · By Simon Chiu

Website Screenshots from the Command Line: 5 CLIs Tested

Take a website screenshot from the command line with headless Chrome, shot-scraper, capture-website or the rs CLI. Timed runs, exit codes and flag maps.

Line illustration of the RenderScreenshot mascot at a terminal that produces a row of snapshots

To take a website screenshot from the command line, run one of these. Each command writes a 1280×800 PNG of the page:

# Google Chrome, already on your machine
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --headless --window-size=1280,800 --screenshot=chrome.png https://example.com

# shot-scraper (Python, Playwright under the hood)
shot-scraper https://example.com -o shot.png --width 1280 --height 800

# capture-website (Node, Puppeteer under the hood)
npx capture-website https://example.com --scale-factor=1 --output=cw.png

# rs, the RenderScreenshot CLI (renders in the cloud, no local browser)
rs take https://example.com --width 1280 --height 800 -o rs.png

The four commands look alike, but they behave very differently once something goes wrong. Chrome returns exit code 0 when DNS fails. shot-scraper and capture-website capture a 404 page as if it were fine. capture-website doubles your pixel count unless you tell it not to. Chrome on a Mac in dark mode gives you a dark screenshot. This page shows what we measured, the flag that fixes each problem, and a batch script that fails loudly.

This post is the command-line chapter of our guide on how to take website screenshots. If you want code instead of a terminal, start there.

Disclosure: I build RenderScreenshot, and rs is our CLI. The three open-source tools below are good, and the sections on them say where each one beats ours.

Tested on 2026-10-15 on macOS 27.0.1 (Apple Silicon, system theme set to Dark) with Google Chrome 155.0.8059.40, chrome-headless-shell 155.0.8059.79, shot-scraper 1.12 (Playwright for Python 1.64.0, Python 3.14.8), capture-website-cli 5.2.0 (capture-website 5.1.0, Puppeteer 24.43.1, Node 22.18.0) and the Playwright CLI 1.64.0. The rs commands follow the CLI reference and were not part of the local timing run (see the method box).

Which command-line tool should you use?

Pick by what you already have and what has to happen when a capture fails.

You need Use Why
One screenshot, nothing to install chrome --headless --screenshot It ships with Chrome. Viewport only, weak error reporting.
Fast local captures in a script chrome-headless-shell Fastest in our runs. Same flags as Chrome, no window chrome, no OS theme.
A list of pages with different settings, kept in Git shot-scraper multi One YAML file describes every shot. JavaScript wait conditions.
Node project, clean marketing captures capture-website Ad blocking on by default, hide/remove/click flags, dark mode flag.
Captures in CI with no browser install, or many pages at once rs Rendering runs on our servers. Content checks, presets, signed URLs, cache.
Screenshots of localhost or a private network Any local tool A cloud renderer cannot reach your laptop. Use rs take --html only for self-contained HTML.

What we measured: same page, same viewport

Every tool captured https://example.com at 1280×800, PNG, device scale factor 1, light colour scheme. The time is wall clock for the whole command, so it includes browser start and shutdown. That is the number a shell loop pays on every URL.

Tool Median time Range (7 runs) Output bytes Dimensions
chrome-headless-shell 155 0.56 s 0.54 to 0.70 s 79,353 1280×800
shot-scraper 1.12 0.94 s 0.90 to 1.06 s 80,922 1280×800
capture-website (direct binary) 1.60 s 1.53 to 1.71 s (5 runs) 80,743 1280×800
capture-website (through npx) 2.02 s 1.99 to 2.32 s 80,743 1280×800
Google Chrome 155, --headless 4.13 s 3.54 to 5.10 s 80,629 1280×800

Method. One MacBook (Apple Silicon), home broadband, 2026-10-15. Each tool ran once as a warm-up (not recorded), then 7 timed runs. The first ever launch of a freshly downloaded headless shell took 7.3 s on macOS because of the system's first-run checks, which is why the warm-up exists. Example.com is a tiny page, so these numbers measure tool overhead, not page rendering. IANA asks people not to use example.com for monitoring, so we kept the run small; point the script at your own site for anything bigger. The script is bench.sh in the test harness and writes a CSV with one row per run.

Three things stand out:

  • The full Chrome binary is slow to start and stop. The page is the same, the engine version is the same, and the headless shell finished seven times faster. If you loop over hundreds of URLs with the full binary, most of your time goes to process start-up.
  • Output bytes match within 2%. All four produce the same image of the same page. Format and scale factor change size far more than the tool does: the same page was 59 KB as JPEG and 36 KB as WebP at quality 80, and 189 KB as PNG at 2× scale.
  • npx costs about 0.4 s per call in our runs. In a loop, call ./node_modules/.bin/capture-website directly.

We did not time rs in this run. Its time is mostly network round trip plus a render on our side, and a repeat request for the same options can come from cache, so a laptop benchmark says little about it. A fair comparison runs from the machine where your jobs run, such as your CI runner.

Chrome's built-in --screenshot

Chrome's headless mode can write a screenshot and exit, with no script at all:

CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"   # macOS
# CHROME=google-chrome                                                  # Linux

"$CHROME" --headless --hide-scrollbars --window-size=1280,800 \
  --blink-settings=preferredColorScheme=1 \
  --screenshot=home.png https://example.com

The file name extension sets the format: --screenshot=home.jpeg writes a JPEG. --force-device-scale-factor=2 gives a 2560×1600 retina image. --print-to-pdf=page.pdf (add --no-pdf-header-footer to drop the date and URL lines) writes a PDF instead.

What surprised us in testing:

  • It follows your OS theme. On a Mac set to Dark, our first capture of example.com came back dark grey, because the page supports prefers-color-scheme. --blink-settings=preferredColorScheme=1 forces light; =0 forces dark. The headless shell, shot-scraper and capture-website all rendered light by default.
  • It exits 0 when the page fails. With an unknown host, Chrome logged Page load failed: net::ERR_NAME_NOT_RESOLVED, wrote no file, and returned exit code 0. A 404 page is captured like any other page, also with exit code 0.
  • It waits for every request. We served a page with one image that never finished loading. Chrome waited 69 seconds, until our test server gave up, then captured. --timeout=15000 caps the wait: Chrome captured whatever had rendered at 15 seconds.
  • It does not wait for late JavaScript. Content that a script adds 1.5 seconds after load was missing from the plain capture. --virtual-time-budget=3000 lets the page's timers run first, and the late content appeared.
  • There is no full-page flag. The capture is the window. A tall --window-size=1280,6000 is the usual workaround, with white space below short pages. For real full-page captures see full-page screenshots in Chrome.

Because Chrome reports success when it fails, wrap it. This script turns "no file" into a non-zero exit:

#!/usr/bin/env bash
# chrome-shot.sh URL OUT.png : headless Chrome with the checks Chrome itself skips.
set -euo pipefail
CHROME=${CHROME:-"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"}
url=${1:?url}; out=${2:-screenshot.png}
rm -f "$out"
"$CHROME" --headless --hide-scrollbars --window-size=1280,800 \
  --blink-settings=preferredColorScheme=1 \
  --timeout=15000 \
  --screenshot="$out" "$url" 2>/dev/null || true
if [ ! -s "$out" ]; then
  echo "chrome-shot: no image for $url" >&2
  exit 1
fi
echo "$out"

The script still captures 404 pages. If that matters, check the status code first with curl -o /dev/null -sw '%{http_code}' "$url".

Use the headless shell for loops

chrome-headless-shell is the old headless browser, now built and shipped separately from Chrome. It takes the same --screenshot and --window-size flags, ignores the OS theme, and was the fastest tool in our table. Install a pinned copy with Puppeteer's browser installer:

npx @puppeteer/browsers install chrome-headless-shell@stable --path ./browsers

The command prints the binary path. Use that path in place of $CHROME above, and drop --headless, because the shell is always headless.

shot-scraper

shot-scraper is a Python CLI built on Playwright. Install it and its browser once:

pipx install shot-scraper   # or: pip install shot-scraper (version 1.12 tested)
shot-scraper install        # downloads Playwright's Chromium

Its defaults are sensible: 1280 wide, light theme, and a full-page capture when you omit --height. Example.com gave a 1280×775 image, the page's real height.

# Viewport capture, retina, wait until a JavaScript condition is true
shot-scraper https://example.com -o [email protected] --width 1280 --height 800 --retina \
  --wait-for "document.querySelector('p')"

# One element, with 10 px of padding
shot-scraper https://example.com -s 'p' -p 10 -o paragraph.png

# WebP at quality 80, and fail on HTTP errors
shot-scraper https://example.com -o home.webp --format webp --quality 80 --fail

--wait-for takes a JavaScript expression and polls until it is true. That is more precise than a fixed delay: our late-content page was captured correctly with --wait-for "document.querySelector('#late')".

Its best feature for teams is multi. One YAML file lists every screenshot, so the file can live next to your docs and run in review:

# shots.yml
- url: https://example.com
  output: shots/home.png
  width: 1280
  height: 800
- url: https://example.com
  output: shots/home-mobile.png
  width: 390
  height: 844
- url: http://localhost:8765/late.html
  output: shots/late.png
  wait_for: document.querySelector('#late')
shot-scraper multi shots.yml

Gaps we hit: there is no colour-scheme flag in 1.12, and passing --browser-arg=--blink-settings=preferredColorScheme=0 had no effect in our test, because Playwright sets the light scheme itself. Use npx playwright screenshot --color-scheme=dark for dark captures. Also remember --fail: without it, a 404 page is saved and the command exits 0.

capture-website

capture-website-cli is a Node CLI on Puppeteer, with the most page-cleaning flags of the open-source tools here:

npm install --save-dev [email protected]

npx capture-website https://example.com --scale-factor=1 --output=home.png
npx capture-website https://example.com --scale-factor=1 --full-page --output=full.png
npx capture-website https://example.com --scale-factor=1 --dark-mode --output=dark.png
npx capture-website https://example.com --scale-factor=1 --element=p --output=para.png
npx capture-website https://example.com --scale-factor=1 \
  --hide-elements='.cookie-banner' --remove-elements='.chat' \
  --wait-for-element='p' --type=webp --quality=0.8 --output=clean.webp

Four defaults to know:

  • --scale-factor defaults to 2. Without --scale-factor=1, our "1280×800" capture came back as 2560×1600 and 188 KB instead of 81 KB. That is good for retina displays and bad for thumbnails and storage bills.
  • Ad blocking is on by default. Pass --no-block-ads if you need the page exactly as a visitor sees it.
  • No --output means stdout. The image bytes go to your terminal. Use --output, or pipe the output to a file on purpose.
  • Quality is 0 to 1, not 0 to 100: --quality=0.8.

On HTTP errors it behaves like shot-scraper: a 404 is captured with exit code 0 unless you add --throw-on-http-error. On our hanging-image test page it returned in about 2 seconds without the stuck image, so do not assume every request finished. Add --wait-for-network-idle or --wait-for-element when the content matters. It also writes PDFs with --type=pdf and the --pdf-* flags.

Playwright's own screenshot command

If a project already depends on Playwright, its CLI has a screenshot command, and it has the dark-mode switch that shot-scraper lacks:

npx playwright screenshot --viewport-size=1280,800 --color-scheme=dark \
  --wait-for-selector=p https://example.com dark.png

It also takes --full-page, --device "iPhone 11", --http-credentials and --load-storage for a saved login session. Its flags are thinner than the dedicated tools, but no extra package is needed.

The same task in every tool

Use this table to translate a command from one tool to another. "None" means we found no flag in the version we tested.

Task Chrome / headless shell shot-scraper 1.12 capture-website 5.2 rs
Viewport size --window-size=1280,800 --width 1280 --height 800 --width=1280 --height=800 --width 1280 --height 800
Retina (2×) --force-device-scale-factor=2 --retina --scale-factor=2 (default) --scale 2
Full page none omit --height --full-page --full-page
One element none -s 'css' --element='css' --element 'css'
Dark mode --blink-settings=preferredColorScheme=0 none --dark-mode --dark-mode
Wait for selector none --wait-for "document.querySelector('css')" --wait-for-element='css' --wait-selector 'css'
Fixed delay --virtual-time-budget=ms --wait ms --delay=seconds --delay ms
Hard timeout --timeout=ms --timeout ms --timeout=seconds --timeout seconds
JPEG / WebP .jpeg extension --format webp --quality 80 --type=webp --quality=0.8 --format webp --quality 80
Transparent background none --omit-background --no-default-background --transparent
HTTP basic auth none --auth-username / --auth-password --authentication=user:pass --auth-basic user:pass
Hide or remove elements none --javascript --hide-elements / --remove-elements --hide / --remove
Block cookie banners none none none (ads only) --block-cookies
Write to stdout none -o - omit --output --stdout
PDF --print-to-pdf=file.pdf shot-scraper pdf --type=pdf --format pdf
Fail on 404 none --fail --throw-on-http-error --fail-if-contains / --fail-if-missing (content checks)

Note the units: Chrome and shot-scraper time out in milliseconds, capture-website and rs in seconds. Mixing them up gives you either a 30-millisecond timeout or an eight-hour one.

Exit codes when the page breaks

Exit codes are what a shell script or CI job actually sees. These are the results from our tests, with default flags unless shown:

Situation Chrome shot-scraper capture-website
Page returns 404 exit 0, image of the 404 page exit 0, image saved exit 0, image saved
404 with the strict flag no flag exit 1, no file (--fail) exit 1, no file (--throw-on-http-error)
Host does not resolve exit 0, no file exit 1 exit 1
One request never finishes waits until it ends (69 s here), exit 0 exit 1 at --timeout exit 0 in about 2 s, stuck image missing

The safe pattern for all of them is the same: write to a temporary file, check the exit code and that the file is a real image, then move it into place. A half-written or missing file then never replaces a good one.

A batch script that fails loudly

This script reads URLs from a file, captures four at a time with shot-scraper, checks the PNG signature, and exits 1 if any URL failed. It is plain Bash plus xargs, so it runs on macOS and Linux:

#!/usr/bin/env bash
# capture-list.sh: screenshot every URL in a file, 4 at a time, fail loudly.
# Usage: ./capture-list.sh urls.txt shots/
set -euo pipefail
LIST=${1:?usage: capture-list.sh urls.txt outdir}
OUTDIR=${2:-shots}
JOBS=${JOBS:-4}
mkdir -p "$OUTDIR"

shoot() {
  local url=$1 outdir=$2
  # Turn the URL into a safe file name: https://a.com/b?c -> a.com_b_c.png
  local name
  name=$(printf '%s' "$url" | sed -E 's#^https?://##; s#[^A-Za-z0-9._-]+#_#g; s#_+$##')
  local tmp="$outdir/.$name.tmp.png"
  if shot-scraper "$url" -o "$tmp" --width 1280 --height 800 \
       --fail --timeout 30000 --silent 2>"$outdir/.$name.err" \
     && [ "$(head -c 8 "$tmp" | od -An -tx1 | tr -d ' \n')" = "89504e470d0a1a0a" ]; then
    mv "$tmp" "$outdir/$name.png"
    rm -f "$outdir/.$name.err"
    echo "ok   $url"
  else
    rm -f "$tmp"
    echo "FAIL $url: $(grep -Eo 'net::ERR_[A-Z_]+|[0-9]{3} error|Timeout [0-9]+ms' "$outdir/.$name.err" | head -1)" >&2
    return 1
  fi
}
export -f shoot

# xargs exits non-zero if any shoot call failed.
grep -Ev '^\s*(#|$)' "$LIST" | xargs -P "$JOBS" -I{} bash -c 'shoot "$@"' _ {} "$OUTDIR"

Our test list had two good URLs, a 404 and a host that does not exist. The output:

FAIL https://no-such-host.invalid: net::ERR_NAME_NOT_RESOLVED
ok   https://example.com/?lang=fr
ok   https://example.com
FAIL https://example.com/no-such-page: 404 error

The exit code was 1, the two good PNGs were saved, and the error log for each failure stayed in the output folder for inspection. Lines appear in completion order, not file order, because four captures run at once. Each parallel job starts its own browser, so keep JOBS near your CPU core count. To run this every night, see scheduled screenshots with cron.

The same jobs with rs

Everything above runs a browser on your machine. That is ideal for localhost, for private networks and for zero running cost. It becomes work when the job moves to CI or a server: a few hundred MB of browser per tool (376 MB for Chrome for Testing and 202 MB for the headless shell on our Mac), system fonts on Linux, and one browser process per parallel capture.

rs sends the capture to the RenderScreenshot API instead. We render with Cloudflare Browser Rendering, store the image in R2 and return it to your terminal, so there is no local browser to install or keep up to date. Install it with Homebrew or go install, or download a release binary (see CLI installation), then give it a key:

brew install render-screenshot/tap/rs
export RS_API_KEY=rs_live_xxxxx      # or run: rs login

The single capture from the table, with the same viewport and a WebP output:

rs take https://example.com --width 1280 --height 800 --format webp --quality 80 -o home.webp

Where it does things the local tools cannot:

# Presets with the right sizes: og_card is 1200x630, twitter_card is 1200x600
rs take https://example.com/blog/post --preset og_card -o og.png

# Clean captures: cookie banners, chat widgets, ads and trackers blocked server-side
rs take https://example.com/pricing --full-page --block-cookies --block-chat --block-ads -o pricing.png

# Content checks in the same command: fail if the page shows an error or lacks a heading
rs take https://staging.example.com/dashboard \
  --wait-selector '#app' \
  --fail-if-contains 'Error,404' --fail-if-missing 'Dashboard' \
  -o dashboard.png || exit 1

# A whole list, with concurrency handled for you
rs batch -f urls.txt --preset desktop_hd -d shots/

# A link anyone can load, which expires in 7 days and does not expose your key
rs signed-url https://example.com --preset og_card --expires 7d

--json returns metadata instead of the image, including the CDN URL and whether the result came from cache. --cache-available returns a cached image only, without a new render. The full flag list is in the CLI command reference, and the CLI examples cover PDFs, logins and localized captures.

Where rs loses, honestly:

  • It needs the network and an API key. The local tools work offline.
  • It cannot see localhost or your VPN. For a local file, send self-contained HTML with rs take --html "$(cat page.html)", but assets that only exist on your machine will not load.
  • Each render uses credits. The free plan needs no credit card and covers trying it out; see pricing for the current limits.
  • For one quick capture on a machine that already has Chrome, the Chrome one-liner is faster to start with.

If your screenshots run in GitHub Actions, the GitHub Actions integration shows the workflow file for rs. Create a free account to get a key, then run the commands above against your own pages.

Troubleshooting

Symptom Cause Fix
Chrome screenshot is dark Full Chrome follows the OS theme in headless mode --blink-settings=preferredColorScheme=1, or use the headless shell
Script reports success, but no file exists Chrome exits 0 on load failure Check [ -s "$out" ] after every call (see chrome-shot.sh)
A 404 page is saved as the screenshot No strict flag shot-scraper --fail; capture-website --throw-on-http-error; rs --fail-if-contains
Image is 2560×1600, not 1280×800 capture-website's scale factor defaults to 2 --scale-factor=1
Binary garbage fills the terminal capture-website without --output writes to stdout Add --output=file.png
Late content is missing The tool captured at load, before your script ran Chrome --virtual-time-budget; shot-scraper --wait-for; capture-website --wait-for-element; rs --wait-selector
Chrome hangs for a minute A request never completes --timeout=15000
Timeout fires instantly or never Milliseconds vs seconds Chrome and shot-scraper use ms; capture-website and rs use seconds
shot-scraper image is very tall No --height means full page Add --height 800 for a viewport capture
shot-scraper says the browser is missing Playwright's Chromium is not installed shot-scraper install
Ads are missing from your capture capture-website blocks ads by default --no-block-ads
First run is several seconds slower First launch of a newly downloaded browser Run a warm-up capture; ignore it in timings
Text shows as boxes on Linux No CJK or emoji fonts in the image Install fonts-noto-cjk and fonts-noto-color-emoji, or render remotely

Summary

For one capture, use the Chrome you already have, and force the colour scheme. For loops on your own machine, use the headless shell or shot-scraper, and always check the exit code and the output file. For marketing captures in a Node project, capture-website's cleaning flags save time once you set --scale-factor=1. When the job moves to CI, needs content checks, or has to run many pages without a local browser, use rs.

Last tested: 2026-10-15. We re-run these commands when Chrome, Playwright or Puppeteer ships a major version.

Related in this topic