Web capture engineering

Loading web fonts reliably in HTML-to-image jobs

A browser can finish parsing and painting before the intended font is usable. The fallback changes line breaks, card height and alignment even if the font swaps in a few milliseconds later.

3 min readPublished Updated
What you actually see

Production images wrap one word earlier than local output, use a system font, or shift between otherwise identical captures.

Reproduce it

  1. 01Throttle the font response and capture once at `DOMContentLoaded`, once after `load`, and once after `document.fonts.ready`.
  2. 02Call `document.fonts.check('600 48px Inter', 'Invoice 1042')` with representative glyphs; a family being loaded does not prove every requested face or glyph is available.
  3. 03Inspect the font response's CORS and content-type headers from the same origin policy the production renderer uses.
The DIY version

Start with the smallest thing that works

Declare the exact face, wait on the CSS Font Loading API, verify it with representative text and only then capture.

html-to-image.mts
await page.setContent(html, { waitUntil: "load" });

await page.evaluate(async () => {
  await document.fonts.ready;
  const wanted = '600 48px "Report Sans"';
  if (!document.fonts.check(wanted, "Quarterly revenue €12,450")) {
    throw new Error(`Font did not load: ${wanted}`);
  }
});

await page.screenshot({ path: "card.png", type: "png" });
Where it breaks

The call is small. Everything around it is the system.

CORS blocks the font

The CSS can be readable while the font request is rejected. The response must allow the controlled document origin, or the browser keeps the fallback.

The wrong weight is available

Loading the family at 400 does not satisfy a 600 face. Synthetic bold can look different across browser and platform versions.

The font lacks a glyph

A Latin sample can pass while currency, CJK or emoji still falls back. Check with text representative of the artifact.

A font is installed only locally

System fonts make a development laptop look correct while a minimal production image uses another fallback. Ship or fetch every non-generic face explicitly.

Keep the DIY version when

  • The template uses a generic system stack and small metric differences do not affect the artifact.
  • All fonts are data URLs inside the controlled HTML and the same Chromium build produces every image.
The recipe

load fonts in HTML to image

Send the HTML rather than hosting a temporary page. The worker waits for `document.fonts.ready` during its settle phase and applies the same outbound policy to font subresources.

Capture settings
Enter the source, choose your options, then run the capture.
Result
Your capture will appear here and stay in view.
waiting

No result yet

Complete the settings and run the tool. Images, PDFs, text, and video all preview in this panel.

Send this exact request
cURL, TypeScript and Python are generated from the same configuration as the demo.
curl --fail-with-body "https://api.pagecapture.dev/v1/take" \
  -H "X-Access-Key: $PAGECAPTURE_KEY" \
  -H "Content-Type: application/json" \
  --data '{"html":"<main id=\"card\" style=\"font:600 48px/1.15 system-ui;padding:72px\"><p style=\"font-size:16px\">Quarterly report</p><h1>Revenue €12,450</h1></main>","format":"png","viewport_width":1200,"viewport_height":630}' \
  -o capture.png
What you get, and what it costs

Output, limits, cost, failures

Output

A PNG from an isolated HTML document after fonts and pending images receive a bounded settle window.

Cost

One successful, non-cached capture costs one credit. Failures, including platform failures, cost zero; cache hits cost zero.

1 creditper successful non-cached capture

Limits

  • Remote fonts must be reachable over HTTPS and pass the same egress policy as every subresource.
  • The settle window is bounded; a font server that never completes cannot hold the job forever.
  • The API cannot license or redistribute a commercial font for you.

Common errors

A configured failed-request policy matches the font URL that did not load.

HTTP 500 · terminal

timeout_errornever billed

Navigation or another readiness rule exhausts the capture budget.

HTTP 500 · retryable

Try it against your own page

The runner above carries this exact configuration into the playground — no retyping, no starting over.

Return to the configured runner
Tested and reviewed

Written by PageCapture Engineering. Reviewed by PageCapture render maintainers. Verified 2026-08-12 by running the published code against a fixture.

The recipe is normalized and generated snippets are syntax-checked; font readiness claims are tied to the worker settle implementation and CSS Font Loading API.

engine-crunknown-pwunknown-f2026-07-1-b2026-07-1StatusInline HTML capture referenceEditorial method

Sources and verification basis

Sources support the browser and API behaviors named above. PageCapture-specific limits and billing are taken from the public contract; external sources are used for the underlying browser behavior.

  1. CSS Font Loading APICSS Working Group

    Defines `FontFaceSet.ready` and `check()`.

  2. Provides the runnable inline-HTML surface used by the recipe.