ScreenshotNeo

BlogHow-to

How to Capture a Mobile Website Screenshot After Fonts and Images Finish Loading

Wait for mobile content, web fonts, and relevant images before capturing. Learn how to handle lazy loading, choose a capture scope, and troubleshoot missing assets.

By the ScreenshotNeo team4 October 20269 min read

To capture a mobile website after its fonts and images finish loading, configure a mobile browser context, navigate to the page, wait for the content you need, await document.fonts.ready, trigger lazy-loaded images if the capture extends below the fold, and check the relevant images before taking the screenshot. Navigation completion alone does not guarantee that a single-page app or lazy-loaded content is ready.

The example below uses Playwright with Node.js. It captures a mobile viewport, uses bounded waits, and reports images that failed to load. Adapt the content selector and image scope to the page you are capturing. See the Playwright Page API and mobile emulation documentation.

1. Set up a mobile browser context

Install Playwright and its Chromium browser in your project:

npm install playwright
npx playwright install chromium

Save this as mobile-shot.mjs. Provide the target URL as the first argument; optionally provide an output filename as the second.

import { chromium, devices } from 'playwright';

const url = process.argv[2];
const output = process.argv[3] ?? 'mobile.png';
if (!url) throw new Error('Usage: node mobile-shot.mjs <url> [output.png]');

const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
  ...devices['iPhone 13'],
  // Set a fixed locale/timezone if the page varies by visitor settings.
  locale: 'en-US',
  timezoneId: 'UTC',
});
const page = await context.newPage();

try {
  await page.goto(url, { waitUntil: 'load', timeout: 30000 });
  // Replace with a selector that signals the content you intend to capture.
  await page.locator('main').waitFor({ state: 'visible', timeout: 15000 });

  // Full-page captures may need to trigger lazy loading first.
  await page.evaluate(async () => {
    const step = Math.max(300, Math.floor(window.innerHeight * 0.75));
    for (let y = 0; y < document.documentElement.scrollHeight; y += step) {
      window.scrollTo(0, y);
      await new Promise(resolve => setTimeout(resolve, 100));
    }
    window.scrollTo(0, 0);
  });

  const result = await page.evaluate(async () => {
    const timeout = ms => new Promise(resolve => setTimeout(() => resolve('timeout'), ms));
    await Promise.race([document.fonts.ready, timeout(10000)]);

    const images = [...document.images];
    const statuses = await Promise.all(images.map(async (image, index) => {
      if (!image.complete) {
        await Promise.race([
          new Promise(resolve => {
            image.addEventListener('load', () => resolve('loaded'), { once: true });
            image.addEventListener('error', () => resolve('error'), { once: true });
          }),
          timeout(10000),
        ]);
      }
      if (image.complete && image.naturalWidth > 0 && image.decode) {
        await Promise.race([image.decode().catch(() => {}), timeout(5000)]);
      }
      return {
        index,
        src: image.currentSrc || image.src,
        loaded: image.complete && image.naturalWidth > 0,
      };
    }));
    return {
      fontsStatus: document.fonts.status,
      failedImages: statuses.filter(image => !image.loaded),
      imageCount: statuses.length,
    };
  });

  if (result.failedImages.length) {
    console.warn('Images not ready:', result.failedImages);
  }
  console.log(`Fonts: ${result.fontsStatus}; images: ${result.imageCount}; failed: ${result.failedImages.length}`);
  await page.screenshot({ path: output, fullPage: false, animations: 'disabled' });
} finally {
  await context.close();
  await browser.close();
}

Run it with node mobile-shot.mjs https://example.com mobile.png. The device preset sets mobile characteristics such as viewport, user agent, and touch behavior. If you need a specific viewport instead, create a context with explicit dimensions and device scale factor. A mobile viewport also makes the page’s viewport meta tag relevant; missing or incorrect viewport metadata can change the layout.

2. Choose the right readiness checks

DOMContentLoaded fires before many resources finish. load waits for the page’s load event, which is a reasonable starting milestone for ordinary assets, but app code may render later. Playwright also supports commit and networkidle. Its documentation discourages using networkidle as a general testing readiness condition: a quiet network for 500 ms does not prove the intended content appeared or that a deferred image was requested. Prefer a locator or page-specific ready signal, then check fonts and images separately.

For example, wait for a product card, headline, or app-specific state rather than sleeping for a guessed duration:

await page.locator('[data-test="product-card"]').first().waitFor({ state: 'visible' });

Wait for fonts

document.fonts.ready resolves when font loading and related layout operations for the document have completed. Run it in the page context with page.evaluate. Keep a timeout around it in production: a broken font service or unusual page behavior should not hang a capture indefinitely. If the timeout expires, decide whether a fallback-font screenshot is acceptable or whether to fail the job.

Check images and decoding

An image’s complete property indicates that loading has completed, including failure. Check naturalWidth > 0 to distinguish successful image data from a failed or empty image. Where supported, decode() lets the browser finish decoding before capture. The example handles both load and error events and bounds each wait.

The sample inspects every img in the document. For a component capture, scope image checks to the target element; for example, evaluate over locator('article').locator('img') or pass a selector into page-side code. CSS background images, canvas content, video frames, and images inside inaccessible cross-origin frames are not covered by document.images; handle those separately if they matter.

3. Handle lazy-loaded content and full-page captures

Browsers can defer images marked with loading="lazy" until they enter or approach the viewport. A full-page screenshot does not necessarily cause every deferred request to start before your image check. Scroll through the content to trigger loading, wait for the relevant sections, then check the images and return to the desired capture position. The sample scrolls in steps; the short delay is only a trigger opportunity, not proof of readiness. Verify image state afterward.

For a very long page, avoid an unbounded scroll loop. Set a maximum page height or capture specific sections, and use a deadline for the overall job. Some sites load more content on scroll; decide whether the capture should include that content, and stop at a defined boundary if the page is effectively infinite.

4. Choose viewport, element, or full-page output

  • Mobile viewport: Use page.screenshot({ path, fullPage: false }) for the visible phone screen. This is the default in the example.
  • Full page: Use fullPage: true to capture the scrollable document. Trigger lazy content before capture and account for exceptionally tall pages.
  • One element: Use page.locator('selector').screenshot({ path }). Wait for that element and its relevant images; element capture avoids unrelated page content.

Playwright’s scale screenshot option accepts css or device. CSS scale produces output at CSS pixel dimensions; device scale uses the device scale factor and can produce a larger image. Choose based on the consumer’s pixel requirements and file-size budget. Keep the browser engine, operating system, installed fonts, device settings, and page state consistent when comparing screenshots: rendering can vary across browsers and platforms. See Playwright visual comparisons.

5. Puppeteer alternative

Puppeteer also supports mobile emulation, page evaluation, and screenshots. The readiness logic remains the same: navigate, wait for meaningful content, await fonts, trigger lazy loading if needed, inspect image state, and capture. A navigation option such as networkidle2 is a milestone, not a guarantee that the visual result is ready.

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
try {
  await page.goto('https://example.com', { waitUntil: 'load', timeout: 30000 });
  await page.waitForSelector('main', { visible: true, timeout: 15000 });
  const readiness = await page.evaluate(async () => {
    await Promise.race([
      document.fonts.ready,
      new Promise((_, reject) => setTimeout(() => reject(new Error('Font wait timed out')), 10000)),
    ]);
    const images = [...document.images];
    await Promise.all(images.map(image => image.complete ? Promise.resolve() : Promise.race([
      new Promise(resolve => {
        image.addEventListener('load', resolve, { once: true });
        image.addEventListener('error', resolve, { once: true });
      }),
      new Promise(resolve => setTimeout(resolve, 10000)),
    ])));
    return images.filter(image => !image.complete || image.naturalWidth === 0).map(image => image.currentSrc || image.src);
  });
  if (readiness.length) console.warn('Images not ready:', readiness);
  await page.screenshot({ path: 'mobile.png' });
} finally {
  await browser.close();
}

6. Troubleshooting

Symptom Likely cause Fix
Fallback font appears Font readiness was not awaited, font request failed, or the intended font is not available in the browser environment. Await document.fonts.ready, inspect the page’s font requests and computed styles, and install or load the expected font in the capture environment.
Some images are blank Image failed, is still loading, or has not been decoded. Check complete and naturalWidth, await decode() when supported, and log currentSrc for failures.
Below-the-fold images are missing Lazy loading deferred their requests until scrolling brought them near the viewport. Scroll the relevant sections into view, wait for their images, then capture.
Screenshot shows a loading shell The navigation event occurred before application rendering finished. Wait for an app-specific selector, state, or response that indicates the content is ready.
Wait hangs or times out A font, image, selector, or page request never completes. Use bounded waits, record the failed resource, and choose whether partial output is acceptable or the capture should fail.
Mobile layout looks like desktop Viewport or mobile emulation is missing, or the page’s viewport configuration is unsuitable. Use a device profile or explicitly set viewport dimensions and mobile behavior; inspect the page’s viewport meta tag.
Images differ between runs Dynamic content, animation, rotating assets, or varying browser and font environments. Pin the rendering environment, disable animations where appropriate, and stabilize or mask dynamic regions for visual comparisons.

7. Performance, reliability, and cost

Every extra wait adds latency, so check only the content that appears in the captured viewport or element unless you need a full page. Prefer one meaningful readiness signal and bounded font/image checks over a long fixed sleep. Full-page scrolling can trigger more network requests and increase output size; cap page height for unbounded feeds. Reuse a browser process for batches of captures where appropriate, while isolating contexts when pages require different cookies or settings.

For reliable jobs, set navigation and readiness timeouts, record failed image URLs and the final page URL, close contexts in a finally block, and decide explicitly whether missing assets should fail the job. A screenshot can be technically generated while still containing fallback fonts or failed images, so expose readiness results to downstream systems. For visual regression, pin browser, platform, fonts, viewport, and device scale; otherwise pixel differences may reflect the environment rather than a site change.

Self-hosted browser automation has no per-shot API charge, but it uses compute, storage, and engineering time. Image scale, full-page height, and file format affect processing and output size. Choose PNG for lossless comparison, or a compressed format when smaller files matter and exact pixel comparison is unnecessary.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. Its one-call API can return a screenshot or PDF without maintaining your own browser capture setup. See the API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));

Cookie banners, popups, and chat widgets are removed before the shot; each cleanup step can be turned off. Bot checks, blank pages, timeouts, and failed loads are never billed, and cache hits cost nothing. An MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Create a free account and get 1,000 screenshots a month with no card.

FAQ

Does document.fonts.ready guarantee every font rendered as intended?

It signals that the document’s font loading work has settled. It does not prove a particular font file succeeded or that the page requested the font you expected. Check computed styles and failed requests when typography still looks wrong.

Should I wait for every image on a page?

Only when the screenshot includes those images. Scope checks to the viewport, selected element, or full-page content you need, and handle failed images according to the purpose of the capture.

Can I take a screenshot of a mobile page without a phone?

Yes. Browser device emulation can set viewport and mobile characteristics for automated capture. It is useful for layout screenshots, though it does not reproduce every property of a physical device.

Why does the same page produce different screenshot pixels?

Browser engine, operating system, fonts, device scale, timing, and dynamic page content can affect rendering. Keep these conditions consistent for meaningful comparisons.