How to Use PhantomJS to Screenshot Pages with Lazy-Loaded Images
Scroll through the page, wait for lazy-loaded images, check which ones loaded, then render. Includes a runnable PhantomJS script and its limits.
To capture lazy-loaded images with PhantomJS, open the page with JavaScript and image loading enabled, scroll down in viewport-sized steps, pause so the page can request and render images, check image loading state, and only then call page.render. Opening a page and immediately rendering it can miss images that have not yet come into view.
This is a best-effort legacy workflow. PhantomJS development is suspended, and a scroll loop cannot guarantee that every site will load every image. The project’s own homepage says, “Important: PhantomJS development is suspended until further notice.” PhantomJS project homepage
1. Install PhantomJS and prepare a script
Install a PhantomJS binary compatible with your operating system, then save the following as screenshot.js. Run it with phantomjs screenshot.js https://example.com output.png. Replace the URL with the page you are authorized to capture.
var page = require('webpage').create();
var system = require('system');
var url = system.args[1];
var output = system.args[2] || 'screenshot.png';
if (!url) {
console.log('Usage: phantomjs screenshot.js <url> [output.png]');
phantom.exit(1);
}
// Set these before page.open: page settings apply to initial navigation.
page.viewportSize = { width: 1365, height: 900 };
page.settings.javascriptEnabled = true;
page.settings.loadImages = true;
page.settings.resourceTimeout = 30000;
page.onError = function (message, trace) {
console.log('Page JavaScript error: ' + message);
};
page.onResourceError = function (resourceError) {
console.log('Resource error: ' + resourceError.url + ' — ' + resourceError.errorString);
};
page.open(url, function (status) {
if (status !== 'success') {
console.log('Could not open page: ' + status);
phantom.exit(2);
return;
}
var scrollStep = Math.max(300, page.viewportSize.height - 100);
var scrollDelayMs = 700;
var finalDelayMs = 1500;
var maxSteps = 200;
var stepCount = 0;
function pageMetrics() {
return page.evaluate(function () {
var body = document.body;
var root = document.documentElement;
var height = Math.max(
body ? body.scrollHeight : 0,
root ? root.scrollHeight : 0,
body ? body.offsetHeight : 0,
root ? root.offsetHeight : 0
);
return {
height: height,
y: window.pageYOffset || (root && root.scrollTop) || 0,
viewportHeight: window.innerHeight || 0
};
});
}
function scrollNext() {
var metrics = pageMetrics();
var nextY = Math.min(metrics.y + scrollStep, Math.max(0, metrics.height - 1));
page.evaluate(function (y) {
window.scrollTo(0, y);
}, nextY);
stepCount += 1;
window.setTimeout(function () {
var afterScroll = pageMetrics();
var reachedBottom = afterScroll.y + afterScroll.viewportHeight >= afterScroll.height - 2;
var heightStoppedGrowing = afterScroll.height <= metrics.height;
if ((reachedBottom && heightStoppedGrowing) || stepCount >= maxSteps) {
window.setTimeout(finishCapture, finalDelayMs);
} else {
scrollNext();
}
}, scrollDelayMs);
}
function finishCapture() {
// Return plain JSON-compatible values from the page context, not DOM nodes.
var imageReport = page.evaluate(function () {
return Array.prototype.map.call(document.images, function (img) {
return {
src: img.currentSrc || img.src || '',
complete: img.complete,
naturalWidth: img.naturalWidth
};
});
});
var failed = imageReport.filter(function (img) {
return !img.complete || img.naturalWidth === 0;
});
if (failed.length) {
console.log('Images still incomplete or failed: ' + failed.length);
failed.forEach(function (img) {
console.log(' ' + img.src + ' (complete=' + img.complete + ', naturalWidth=' + img.naturalWidth + ')');
});
} else {
console.log('All inspected image elements report complete with nonzero naturalWidth.');
}
var finalMetrics = pageMetrics();
// Capture the document bounds. Validate output dimensions with your PhantomJS build.
page.clipRect = {
top: 0,
left: 0,
width: page.viewportSize.width,
height: Math.max(page.viewportSize.height, finalMetrics.height)
};
var rendered = page.render(output);
console.log((rendered ? 'Saved ' : 'Failed to save ') + output);
phantom.exit(rendered ? 0 : 3);
}
// Give the initial document a moment to settle before starting the scroll-through.
window.setTimeout(scrollNext, 500);
});
The script logs resource errors and reports image elements that are incomplete or have a zero naturalWidth before rendering. Treat the report as a diagnostic, not proof that every visual asset loaded: CSS backgrounds, images inserted after the check, and page-specific custom loaders are outside this list. Confirm that the generated file has the dimensions and clipping you expect in the PhantomJS build you deploy.
2. Why scrolling triggers lazy loading
Many pages delay offscreen image requests until an image approaches the viewport. Scrolling in steps gives viewport- and scroll-triggered loading code an opportunity to run; a brief pause lets requests begin and images render. Browser-level lazy loading is also designed around proximity to the viewport. See Google Chrome Developers’ overview of browser-level image lazy loading.
The overlap in the example (100 pixels) reduces the chance of stepping past a narrow trigger region. Increase the delay for slow pages or reduce it for pages that load quickly, while keeping the final image check. No fixed delay works for every network and site.
3. Configure navigation, scrolling, and output
| Setting or choice | What it does | Practical guidance |
|---|---|---|
javascriptEnabled |
Allows page JavaScript to run; documented default is true. | Set before page.open when the page relies on JavaScript. |
loadImages |
Allows image loading; documented default is true. | Set before navigation. Changing page settings later does not affect the initial navigation. |
viewportSize |
Sets the browser viewport dimensions. | Choose the target width and height before opening the URL; viewport size affects responsive layout and lazy-loading thresholds. |
| Scroll step | Controls how far each scripted scroll moves. | Use about one viewport height, with overlap. Smaller steps can trigger more reliably but take longer. |
| Per-step and final waits | Give scripts and image requests time to run. | Tune to the site and network. A wait is not a guarantee that requests succeeded. |
page.clipRect and page.render |
Set a capture region and save the rendered output. | Decide whether you need a viewport capture or the full document. Verify full-page dimensions and clipping in your deployed build. |
| Output format | page.render supports formats including PDF, PNG, JPEG, BMP, and PPM; GIF depends on the Qt build. |
Use an extension that matches the intended format and verify the result. This example writes PNG. |
PhantomJS documents its page settings, page.evaluate, and page.render. The screen-capture guide also describes viewport and clipping configuration.
4. Handle page evaluation and image edge cases
page.evaluate runs code in the page’s context, separated from the PhantomJS script. The callback cannot access PhantomJS-side variables or return DOM nodes. Pass simple arguments, such as the scroll coordinate, and return JSON-serializable values, such as numbers, strings, booleans, and arrays of plain objects.
- Images still loading: increase the per-step or final wait, then rerun the image check. A failed request will not be fixed by waiting longer.
- Document keeps growing: infinite-scroll pages may add content as you reach the bottom. The example has a 200-step cap; adjust it to your expected page length and inspect the final height.
- Custom lazy loading: a site may use a placeholder attribute, interaction, or application state instead of standard image loading. Inspect the page’s markup and loading behavior; scrolling alone may not activate it.
- Images outside
document.images: CSS background images and canvas-rendered content are not covered by the image report. Inspect those separately if they matter to the capture. - Redirects, blocked requests, or authentication: verify the final page and resource errors. A successful page open does not mean all assets succeeded.
- Very long pages: large clip heights may exceed limits in a particular build or consume substantial memory. Capture sections or reduce output dimensions if needed.
- Responsive layouts: the page may serve different images at different widths. Set the intended viewport before navigation and confirm the selected
currentSrc.
5. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Images are blank in the screenshot | The page was rendered before scrolling or before image requests finished; image loading may also be disabled. | Enable loadImages before opening, scroll through the page, wait, and inspect complete and naturalWidth. |
| Only the top of the page appears | The render region is viewport-sized or the clip height was not set as expected. | Set the document clip deliberately, then check output dimensions. PhantomJS build behavior can vary; section captures may be more dependable for very long pages. |
| Navigation reports failure | The page did not finish opening, the server rejected the request, or the resource timed out. | Check the URL, network access, resource errors, and timeout. Determine whether the page requires authentication or browser capabilities PhantomJS lacks. |
| Some images have zero natural width | The request failed, the image is still pending, or the page uses a nonstandard lazy-loading mechanism. | Review the logged URL and resource error; wait longer for pending requests, and inspect the page’s image markup and custom loading code. |
page.evaluate returns an unexpected value |
The callback tried to use an outer PhantomJS variable or returned a non-serializable object. | Pass values as arguments and return simple JSON-compatible data. |
| Modern pages fail or render incorrectly | PhantomJS uses QtWebKit and is a suspended project; current JavaScript and browser APIs may not be supported. | Check whether the page depends on newer browser features. For new workflows, evaluate a maintained browser automation option against your compatibility and deployment needs. |
6. Performance, reliability, and cost
Each scroll step adds a wait, so a long page can take roughly the number of steps multiplied by the per-step delay, plus navigation and the final settling period. Smaller steps, longer waits, and larger output dimensions increase capture time or memory use. Tune the viewport and waits to the page, and set a sensible step limit for pages that append content indefinitely.
For reliability, record navigation status, resource errors, image failures, final page height, and output dimensions. On a site you control, disabling lazy loading for the capture route or exposing an explicit page-ready signal can be more dependable than guessing with delays. PhantomJS itself has no service cost in this workflow; account for the machine, runtime maintenance, and time spent diagnosing site-specific failures. Its suspended development is a key maintenance and compatibility concern.
7. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server for developers. One request captures a URL, and its full-page capture loads lazy images. Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed, and response headers report the page verdict and billing status. AI agents can use its MCP tools to take screenshots. The free plan includes 1,000 shots a month with no card; paid plans start at $5 for 3,000 shots. See the ScreenshotNeo API documentation.
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,
)
r.raise_for_status()
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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.
8. FAQ
Does PhantomJS automatically capture lazy-loaded images?
Do not assume so. A full-page render does not itself guarantee that a page’s lazy-loading code was triggered; scroll through the page and verify image state first.
Does a successful page.open mean every image loaded?
No. It reports navigation status, not successful completion of every image request. Inspect image state and resource errors.
Can I use this workflow on every modern site?
No. PhantomJS development is suspended, so compatibility with current browser APIs is not assured. Treat it as a legacy workflow and check the actual target page.
Can I return the image report from page.evaluate?
Yes. Return arrays or objects containing JSON-compatible values, as the example does. Do not return DOM elements or rely on PhantomJS variables inside the page callback.


