PhantomJS is finished. Its maintainer stepped down in 2017, development was suspended in 2018, and the last release (2.1.1) shipped in early 2016 with a WebKit engine from before CSS grid and modern JavaScript. If a PhantomJS script still renders screenshots or PDFs somewhere in your stack, it is producing captures of a web that no longer exists, from a binary that gets no security fixes. You have two practical replacements: Playwright, if you want to keep running a browser yourself, or a screenshot API, if all the script does is produce images or PDFs. This page maps every common PhantomJS call to both and gives tested replacements.
Disclosure: I build RenderScreenshot, which is one of the two options here. For many teams the Playwright route is the right one, and the code for it is below too.
This page is part of our screenshot API comparison series. If you are also replacing other legacy tools, see the wkhtmltoimage alternative.
Verdict
| Migrate to Playwright if | Migrate to RenderScreenshot if |
|---|---|
Your PhantomJS script logs in, clicks or scrapes values with page.evaluate |
Your script only opens a page and calls page.render |
| You must render pages only your server can reach | The pages are public, or reachable with cookies or headers |
| You are ready to run and update Chromium on your servers | You want no browser on your servers at all |
| You want the most control over each step | You want presets, caching and retries handled for you |
Either way, do not try to keep PhantomJS alive. Its old WebKit cannot lay out many current sites, and no amount of setTimeout fixes missing CSS and JavaScript features.
The script most teams still run
Most surviving PhantomJS code is a variant of the classic rasterize script: open a URL, wait a moment, render to PNG or PDF. Something like this:
// capture.js: legacy PhantomJS 2.1 script (ES5). Run with: phantomjs capture.js <url> <out.png|out.pdf> // Shown for reference; PhantomJS is not maintained. var page = require('webpage').create(); var system = require('system'); var url = system.args[1]; var out = system.args[2] || 'out.png'; page.viewportSize = { width: 1280, height: 800 }; page.clipRect = { top: 0, left: 0, width: 1280, height: 800 }; page.settings.userAgent = 'Mozilla/5.0 (capture-bot)'; page.settings.resourceTimeout = 10000; page.customHeaders = { 'Accept-Language': 'en-US' }; page.paperSize = { format: 'A4', orientation: 'portrait', margin: '1cm' }; page.onResourceRequested = function (data, request) { if (/\.(mp4|webm)$/.test(data.url)) request.abort(); }; page.open(url, function (status) { if (status !== 'success') { console.log('load failed: ' + status); phantom.exit(1); } // Give scripts time to run, then render. window.setTimeout(function () { page.render(out); phantom.exit(0); }, 1000); });
We did not run this script for this page: PhantomJS has no native build for Apple Silicon Macs, and installing it on a server today is exactly what this page argues against.
PhantomJS to Playwright to RenderScreenshot: API mapping
| PhantomJS | Playwright | RenderScreenshot |
|---|---|---|
require('webpage').create() |
chromium.launch() then browser.newContext() and context.newPage() |
Not needed |
page.viewportSize = { width, height } |
newContext({ viewport: { width, height } }) |
viewport.width, viewport.height |
page.zoomFactor = 2 |
newContext({ deviceScaleFactor: 2 }) |
viewport.scale: 2 |
page.clipRect = { top, left, width, height } |
page.screenshot({ clip: { x, y, width, height } }) |
capture.clip |
page.render('out.png') |
page.screenshot({ path: 'out.png' }) |
The response body (PNG by default) |
page.render('out.jpg', { quality: 80 }) |
page.screenshot({ type: 'jpeg', quality: 80 }) |
output.format: "jpeg", output.quality: 80 |
page.render('out.pdf') with page.paperSize |
page.pdf({ format: 'A4', margin }) |
output.format: "pdf", pdf.paper, pdf.margin, pdf.landscape |
page.settings.userAgent |
newContext({ userAgent }) |
browser.user_agent |
page.settings.resourceTimeout |
page.goto(url, { timeout }) |
wait.timeout (seconds, max 60) |
page.customHeaders |
newContext({ extraHTTPHeaders }) |
network.headers |
phantom.addCookie() |
context.addCookies([...]) |
network.cookies |
page.settings.userName / password |
newContext({ httpCredentials }) |
network.auth with type: "basic" |
page.onResourceRequested + request.abort() |
page.route(pattern, (r) => r.abort()) |
block.requests (glob) or block.resources (types) |
page.injectJs() / page.includeJs() |
page.addInitScript() / page.addScriptTag() |
page.scripts (inline or { url }) |
page.evaluate(fn) returning data |
page.evaluate(fn) |
No equivalent (the API returns images and PDFs) |
window.setTimeout(render, 1000) |
waitUntil: 'networkidle' or locator.waitFor() |
wait.until, wait.for_selector, wait.delay |
page.open(url, cb) status 'fail' |
page.goto() throws or returns a non-OK response |
HTTP error with a typed code and retryable |
system.args |
process.argv |
Not applicable |
The RenderScreenshot names are documented in the parameter reference; PDF options are on the PDF parameters page.
Route 1: the same script in Playwright
// capture-playwright.mjs: drop-in replacement for a PhantomJS capture script. // Usage: node capture-playwright.mjs https://example.com out.png (or out.pdf) import { chromium } from 'playwright'; const [, , url, out = 'out.png'] = process.argv; if (!url) { console.error('usage: node capture-playwright.mjs <url> <out.png|out.pdf>'); process.exit(2); } const browser = await chromium.launch(); try { const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, // page.viewportSize deviceScaleFactor: 1, // page.zoomFactor (for sharper output use 2) userAgent: process.env.CAPTURE_UA, // page.settings.userAgent (undefined = default) extraHTTPHeaders: { 'Accept-Language': 'en-US' }, // page.customHeaders colorScheme: 'light', }); const page = await context.newPage(); // page.onResourceRequested + networkRequest.abort() await page.route(/\.(mp4|webm)$/, (route) => route.abort()); // page.open(url, cb) + status check + the setTimeout you used to wait for JS const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 }); if (!response || !response.ok()) throw new Error(`load failed: HTTP ${response?.status()}`); if (out.endsWith('.pdf')) { await page.pdf({ path: out, format: 'A4', printBackground: true }); // page.paperSize + render() } else { // page.clipRect + page.render() await page.screenshot({ path: out, clip: { x: 0, y: 0, width: 1280, height: 800 } }); } console.log(`saved ${out}`); } finally { await browser.close(); }
npm i [email protected] npx playwright install chromium # on Linux servers: npx playwright install --with-deps chromium node capture-playwright.mjs https://example.com out.png
Tested on 2026-10-15 with Node.js 22.18.0 and Playwright 1.64.1 on an Apple M1 Pro:
| Run | Target | Result | Wall time |
|---|---|---|---|
| PNG, 3 runs | https://example.com |
1280x800 PNG, 80,922 bytes | 1.32 s, 2.20 s, 1.30 s |
https://example.com |
A4 PDF | saved | |
| Bad page | https://example.com/nope-404 |
Error: load failed: HTTP 404, exit code 1 |
n/a |
| No arguments | n/a | Usage message, exit code 2 | n/a |
The wall times include starting Chromium, because each call launches a new browser like each phantomjs call did. If your old setup called PhantomJS once per URL from a shell loop, keep one browser open and loop inside the script instead; that removes most of the per-capture time.
Three changes trip people up in this migration:
colorSchemematters now. PhantomJS had no dark mode. Modern Chromium follows the host's colour scheme unless you set one, and on a Mac in dark mode we sawexample.comrender dark with the Chrome CLI. SetcolorScheme: 'light'as above to keep old and new output alike.- Promises replace callbacks. PhantomJS scripts are ES5 with callbacks and
phantom.exit(). In Node.js, useawait,try/finallyto close the browser, and a non-zero exit code on failure, as above. - Replace fixed sleeps with conditions. The
setTimeout(render, 1000)pattern existed because PhantomJS had nothing better.waitUntil: 'networkidle'or waiting for a specific element is faster on quick pages and more reliable on slow ones. Our Playwright screenshot tutorial covers the wait options in depth.
Route 2: no browser at all
If the script only renders, replace it with an API call that takes the same two arguments. The browser, its updates and its fonts move off your server.
Node.js
// rasterize-api.mjs: same arguments as the PhantomJS script, no browser on this machine. // Usage: node rasterize-api.mjs <url> <out.png|out.pdf> import { writeFile } from 'node:fs/promises'; import { Client, TakeOptions } from 'renderscreenshot'; const [, , url, out = 'out.png'] = process.argv; if (!url) { console.error('usage: node rasterize-api.mjs <url> <out.png|out.pdf>'); process.exit(2); } const client = new Client(process.env.RS_API_KEY); const options = out.endsWith('.pdf') ? TakeOptions.url(url).format('pdf').pdfPaperSize('a4').pdfPrintBackground() : TakeOptions.url(url).width(1280).height(800).waitFor('networkidle').format('png'); try { await writeFile(out, await client.take(options)); console.log(`saved ${out}`); } catch (err) { console.error(`capture failed: ${err.code ?? ''} ${err.message}`); process.exitCode = 1; }
Python
"""rasterize_api.py: PhantomJS rasterize replacement in Python. Usage: python rasterize_api.py <url> <out.png|out.pdf>""" import os import sys import requests if len(sys.argv) < 2: sys.exit("usage: python rasterize_api.py <url> <out.png|out.pdf>") url = sys.argv[1] out = sys.argv[2] if len(sys.argv) > 2 else "out.png" if out.endswith(".pdf"): body = {"url": url, "output": {"format": "pdf"}, "pdf": {"paper": "a4", "background": True}} else: body = { "url": url, "viewport": {"width": 1280, "height": 800}, "wait": {"until": "networkidle"}, "output": {"format": "png"}, } resp = requests.post( "https://api.renderscreenshot.com/v1/screenshot", headers={"Authorization": f"Bearer {os.environ['RS_API_KEY']}"}, json=body, timeout=90, ) if resp.status_code != 200: sys.exit(f"capture failed: HTTP {resp.status_code} {resp.text[:200]}") with open(out, "wb") as f: f.write(resp.content) print(f"saved {out}")
Ruby
# rasterize_api.rb: PhantomJS rasterize replacement in Ruby. Usage: ruby rasterize_api.rb <url> <out.png|out.pdf> require 'renderscreenshot' url = ARGV[0] or abort('usage: ruby rasterize_api.rb <url> <out.png|out.pdf>') out = ARGV.fetch(1, 'out.png') client = RenderScreenshot.client(ENV.fetch('RS_API_KEY')) options = if out.end_with?('.pdf') RenderScreenshot::TakeOptions.url(url).format('pdf').pdf_paper_size('a4').pdf_print_background else RenderScreenshot::TakeOptions.url(url).width(1280).height(800).wait_for('networkidle').format('png') end begin File.binwrite(out, client.take(options)) puts "saved #{out}" rescue RenderScreenshot::Error => e abort "capture failed: #{e.code} #{e.message}" end
All three were tested on 2026-10-15 against a local mock of the RenderScreenshot API for PNG output, PDF output and the missing-argument case (Node.js 22.18.0 with renderscreenshot 1.1.0; Python 3.14 with requests 2.34.2; Ruby 4.0.7 with the renderscreenshot gem 1.0.0). They check the request each one sends; they do not measure our rendering. Because each script keeps the PhantomJS argument order, a shell loop or cron line that called phantomjs capture.js "$url" "$file" only needs its command changed.
What changes in the output
- Pages render correctly. This is the point of the migration, but it means images will not match your old ones pixel for pixel. Re-baseline any visual tests after the switch.
- Fonts. PhantomJS used the fonts on your server. Playwright on Linux does too, so install the font packages your pages need. The API renders with our font set; send
page.styleswith@font-facerules if you need a specific font. - PDF page breaks. Chromium supports more of the CSS paged-media rules (
@page,break-inside) than PhantomJS did, so page breaks can move. Check long tables and headers after the switch.
Cost at 1,000, 10,000 and 100,000 captures a month
Playwright is free; its cost is a server with enough memory for Chromium and the time to keep it updated. RenderScreenshot plans, checked 2026-10-15 (see pricing):
| Monthly captures | Plan | Monthly price |
|---|---|---|
| Up to 150 | Free, no credit card | $0 |
| 1,000 | Starter (2,500 credits) | $10 |
| 10,000 | Growth (12,000 credits) | $40 |
| 100,000 | Above Scale (60,000 credits for $100): custom plan | Quote |
When Playwright is the better replacement
- Your PhantomJS code does more than render: it fills forms, clicks, or returns values from
page.evaluate. - The pages live on an internal network the API cannot reach.
- You already run Node.js services and can add Chromium to the image.
- Capture volume is high and steady enough that a dedicated server is cheaper than per-capture pricing.
The Playwright alternative and Puppeteer alternative pages cover the next decision: when to keep running those yourself and when to hand captures to an API.
Retire PhantomJS this week
- Find every call: search your repositories and crontabs for
phantomjsand for.render(. - For each script, decide with the verdict table: Playwright or API.
- Port it with the mapping table, keeping the same arguments so callers do not change.
- Run old and new side by side on ten real URLs and check the output.
- Remove the PhantomJS binary from your images and servers.
To try the API route, create a free key and run rasterize-api.mjs with the URLs your PhantomJS job uses today.