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.
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
rsis 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
rscommands 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.
npxcosts about 0.4 s per call in our runs. In a loop, call./node_modules/.bin/capture-websitedirectly.
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=1forces light;=0forces 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=15000caps 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=3000lets 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,6000is 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-factordefaults 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-adsif you need the page exactly as a visitor sees it. - No
--outputmeans 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 |
--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
localhostor your VPN. For a local file, send self-contained HTML withrs 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
- Taking Screenshots with Playwright: The Complete Guide Learn how to capture web page screenshots with Playwright — from installation and browser contexts to full-page captures, element screenshots, and production-ready patterns.
- Taking Screenshots with Puppeteer: A Practical Guide Learn how to capture web page screenshots with Puppeteer and headless Chrome in Node.js — from basic usage to full-page captures, viewports, and production pitfalls.
- Dark Mode Screenshots with Puppeteer and Playwright Learn how to capture dark mode screenshots with Puppeteer and Playwright — including prefers-color-scheme emulation, class-based dark mode, dual-mode capture, and edge cases with images and third-party embeds.