How to Take Full-Page Screenshots in Elixir
Capture an entire scrollable page from Elixir by calling Playwright, understand Wallaby’s limits, and compare a browser-free ScreenshotNeo workflow.

Direct answer: Playwright captures a complete scrollable document when you pass fullPage: true to page.screenshot. The basic call is:
await page.screenshot({ path: "page.png", fullPage: true });
There is no verified, documented Elixir binding in the sources reviewed for this guide. In an Elixir application, the dependable pattern is to run Playwright in a small Node.js process and invoke that process with System.cmd/3. Wallaby is an Elixir browser-testing project, but its repository documentation does not establish that a Wallaby session exposes Playwright’s fullPage screenshot option. Treat Wallaby and Playwright as separate integration choices until you verify the exact driver and version used by your project.
What “full page” means
Playwright defines a full-page screenshot as an image of the entire scrollable page, as if the page were displayed on a very tall screen. The default for fullPage is false, so a normal screenshot contains only the current viewport. Setting it to true makes Playwright calculate the document’s scrollable dimensions and capture the whole page.
The path option writes the resulting image to disk. Playwright also documents controls such as image quality and scale. Quality applies to JPEG output; PNG output is lossless. A page can be visually long while still containing lazy-loaded content, sticky headers, animations, or infinite-scroll behavior, so “full page” does not automatically mean “every item a user could ever load.”
Recommended Elixir architecture
Use two processes:

- An Elixir process receives the URL and output path.
- A short Node.js script launches Chromium, opens the URL, waits for the page state you choose, and calls Playwright’s documented screenshot API.
This boundary keeps browser lifecycle and JavaScript APIs in the environment where they are documented. It also makes browser failures visible to Elixir through the command’s exit status and stderr.
Set up Playwright
Install Node.js and create a small helper directory in your project:
mkdir -p priv/browser
cd priv/browser
npm init -y
npm install playwright
npx playwright install chromium
The browser download is separate from the npm package. In CI, run the install command during image creation or dependency setup rather than on every screenshot request.
Complete Node.js capture script
Create priv/browser/full_page_screenshot.mjs:
import { chromium } from "playwright";
const [url, outputPath] = process.argv.slice(2);
if (!url || !outputPath) {
console.error("Usage: node full_page_screenshot.mjs URL OUTPUT_PATH");
process.exit(2);
}
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1
});
await page.goto(url, {
waitUntil: "networkidle",
timeout: 60_000
});
await page.screenshot({
path: outputPath,
fullPage: true,
type: "png"
});
console.log(outputPath);
} finally {
await browser.close();
}
networkidle is useful for pages that finish loading their assets, but some sites keep analytics or websocket connections open indefinitely. For those pages, use domcontentloaded followed by an explicit selector wait or a short delay:
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
await page.waitForSelector("main", { timeout: 15_000 });
await page.waitForTimeout(1_000);
await page.screenshot({ path: outputPath, fullPage: true });
Call Playwright from Elixir
The following module is executable when Node.js, the helper script, and Chromium are installed. It validates the URL, creates the destination directory, and returns either {:ok, path} or a useful error.
defmodule FullPageScreenshot do
@script Path.expand("priv/browser/full_page_screenshot.mjs")
def capture(url, output_path) when is_binary(url) and is_binary(output_path) do
uri = URI.parse(url)
if uri.scheme not in ["http", "https"] or is_nil(uri.host) do
{:error, :invalid_url}
else
output_path
|> Path.dirname()
|> File.mkdir_p!()
case System.cmd("node", [@script, url, output_path], stderr_to_stdout: true) do
{_output, 0} -> {:ok, output_path}
{output, status} -> {:error, {:browser_failed, status, output}}
end
end
end
end
case FullPageScreenshot.capture(
"https://example.com",
"tmp/example-full.png"
) do
{:ok, path} -> IO.puts("Saved #{path}")
{:error, reason} -> raise "Screenshot failed: #{inspect(reason)}"
end
Run it from the project root with:
elixir -r lib/full_page_screenshot.ex -e 'FullPageScreenshot.capture("https://example.com", "tmp/example-full.png")'
For a Phoenix application, call the module from a supervised worker rather than from a web request when captures can take tens of seconds. Limit concurrent browser processes so a burst of requests does not exhaust memory.
Useful Playwright options
| Option | Use | Important detail |
|---|---|---|
fullPage |
Capture the full scrollable document | Defaults to false. |
path |
Write the image to a file | Parent directories must exist. |
type |
png or jpeg |
Use JPEG when file size matters; quality controls JPEG compression. |
quality |
Set JPEG quality | Do not use it with PNG. |
scale |
Control CSS-pixel versus device-pixel output | Check the resulting dimensions when using high-density devices. |
omitBackground |
Allow transparency where supported | Useful for PNG assets with transparent page backgrounds. |
Set the viewport before navigation. Responsive breakpoints are selected from the viewport width, so a 375-pixel mobile capture and a 1440-pixel desktop capture can have completely different document heights. Use deviceScaleFactor: 2 for retina-like output, but expect larger files and more memory use.
Lazy loading, sticky elements and dynamic pages
Lazy-loaded images
Some pages load images only after they approach the viewport. A full-page screenshot may therefore contain placeholders if the page never receives scroll events. You can progressively scroll before taking the final image:
await page.evaluate(async () => {
await new Promise((resolve) => {
let y = 0;
const step = 700;
const timer = setInterval(() => {
window.scrollBy(0, step);
y += step;
if (y >= document.body.scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0);
resolve();
}
}, 100);
});
});
await page.waitForTimeout(500);
await page.screenshot({ path: outputPath, fullPage: true });
This is a page-specific workaround, not a guarantee for infinite-scroll feeds. Stop after a defined number of scrolls or a maximum height to avoid an unbounded job.
Sticky headers
A fixed header can appear repeatedly in stitched output or cover content. Hide it with a targeted style only when that matches your capture goal:
await page.addStyleTag({
content: `header, .sticky, [data-sticky] { position: static !important; }`
});
Animations and blinking content
Freeze animations before capture for deterministic images:
await page.addStyleTag({
content: `*, *::before, *::after {
animation: none !important;
transition: none !important;
caret-color: transparent !important;
}`
});
When Wallaby is involved
Wallaby provides Elixir browser-testing workflows, but the reviewed repository and documentation do not show a verified API that maps to Playwright’s page.screenshot({ fullPage: true }). Do not assume that a Wallaby screenshot helper accepts Playwright options. Check the version-specific Wallaby documentation, selected driver, and browser capabilities before designing around it.
If your test suite already uses Wallaby, keep test interaction there and delegate the full-page artifact to the Node helper above. This avoids silently relying on an unsupported option. Confirm the project’s Elixir and OTP requirements from the repository before pinning versions; those requirements are version-sensitive.
Security and reliability checklist
- Allow only approved URL schemes and, for internal tools, an allowlist of hosts.
- Apply navigation and selector timeouts. Never let an unbounded page hang a worker.
- Run Chromium with an isolated user data directory for jobs that handle private cookies.
- Do not log authorization headers, cookies, or page contents.
- Store output files outside a public directory until access checks complete.
- Reuse a browser process carefully for throughput, but create isolated browser contexts per job.
- Set a maximum output height or file size for untrusted pages.
- Capture diagnostics: URL, viewport, elapsed time, exit status, and a short stderr message.
For repeatable builds, pin the npm and Playwright versions in package-lock.json and install the matching browser revision in CI. Browser binaries are large; cache them between builds where your CI provider supports it.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Executable doesn't exist |
Chromium was not downloaded | Run npx playwright install chromium in the same environment. |
| Only the viewport is captured | fullPage was omitted or false |
Pass fullPage: true to page.screenshot. |
| Images are blank | Lazy loading or capture happened too early | Scroll the page, wait for a selector or image count, then capture. |
networkidle never resolves |
Persistent analytics, polling or websockets | Use domcontentloaded plus explicit waits and a bounded delay. |
| Elixir reports status 1 | Node helper failed | Return and inspect stderr from System.cmd; run the exact command manually. |
| Output directory error | Parent directory does not exist or is not writable | Create it with File.mkdir_p! and check process permissions. |
| Text differs between runs | Fonts, animations, time, ads or geolocation vary | Install required fonts, freeze animation, set locale/timezone, and block nondeterministic resources where appropriate. |
Performance and cost considerations
Full-page captures consume more memory than viewport screenshots because the browser must render a taller document and encode a larger bitmap. Narrowing the viewport, selecting JPEG, reducing the device scale factor, and removing unnecessary resources can reduce work. Measure your own pages because layout complexity, image count and font loading dominate runtime.
For a self-hosted setup, account for Chromium memory, browser downloads, worker concurrency and storage. A failed command still consumes your infrastructure resources even when no image is produced. If you capture many URLs, queue jobs and apply backpressure instead of launching unlimited OS processes.
Or skip the browser setup
ScreenshotNeo provides a single GET request for a PNG, JPEG, WebP or PDF. Its API can load lazy images, select an element, set a viewport or device preset, use retina scale, apply custom CSS or JavaScript, click before capture, wait for a selector, delay or network idle, and control headers, cookies, user agent, authorization, timezone and geolocation. You can also block ads, trackers, requests or resource types, resize images, cache with a chosen TTL, submit asynchronous jobs, capture up to 100 URLs per call and use signed links or webhooks.

Use the documented parameters in the ScreenshotNeo API documentation. A minimal call from Elixir can use Erlang’s HTTP client:
:inets.start()
:ssl.start()
url = "https://stripe.com"
key = System.fetch_env!("SCREENSHOTNEO_ACCESS_KEY")
request_url = "https://api.screenshotneo.com/v1/shot?access_key=#{URI.encode(key)}&url=#{URI.encode(url)}"
case :httpc.request(:get, {String.to_charlist(request_url), []}, [], body_format: :binary) do
{:ok, {{_, 200, _}, _headers, body}} -> File.write!("shot.webp", body)
{:ok, {{_, status, _}, _headers, body}} -> raise "ScreenshotNeo returned #{status}: #{body}"
{:error, reason} -> raise "Request failed: #{inspect(reason)}"
end
Equivalent requests are useful when you prefer a dedicated HTTP client:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, newsletter popups and chat widgets are removed before the shot. Bot checks, blank pages, failed loads and cache hits are not billed, and response headers identify the page verdict and billing result. An MCP server lets Claude, Cursor and other MCP clients call take_screenshot, get_page_info and capture_pdf. The Free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
FAQ
Does fullPage: true capture an infinite-scroll feed?
No. It captures the document that exists when the screenshot runs. Scroll and load a bounded amount of content first, or use a service that supports page-specific waiting and scripting.
Can I produce JPEG instead of PNG?
Yes. Set type: "jpeg" and provide a quality value appropriate for your use case.
Should I use Wallaby or Playwright?
Use the toolchain your project can verify. Playwright documents the full-page screenshot API. The reviewed Wallaby material does not establish equivalent support, so confirm it before depending on it.
How do I capture one element rather than the whole page?
Playwright supports an element locator screenshot. Locate the element, then call its screenshot method; this is different from the page-level fullPage option.
Where should browser failures be handled?
Return the Node process exit status and stderr to Elixir, classify timeouts separately from launch failures, and retry only errors that are safe to repeat.


