Chrome Headless screenshot font rendering is blurry: how to fix it
Blurry Headless Chrome text can come from image scaling, a fallback font, or platform rasterization. Diagnose each cause with a fixed, reproducible capture.
To fix blurry text in a Chrome Headless screenshot, first determine whether the problem is incorrect image scaling, a fallback or late-loading font, or platform-specific text rendering. These symptoms can look similar but need different fixes. Start with a fixed viewport and device scale factor of 1, capture without resizing the PNG, confirm the actual rendered font, and then compare the same Chrome version and operating system. There is no single Chrome flag that reliably fixes every kind of blurry text.
1. Identify what “blurry” means
Before changing launch flags or CSS, describe the visible difference. Is the text soft at the edges, lighter than expected, unexpectedly large or small, or a different shape and width? Those observations help narrow the investigation; they are a diagnostic starting point, not a formal Chrome classification.
| What you see | First thing to check |
|---|---|
| All content looks soft, or the screenshot has unexpected pixel dimensions | Viewport, device scale factor, and any image resizing after capture |
| Text has a different shape, width, or weight | The actual rendered typeface, font request, and selected font weight |
| Text looks pale or washed out, but geometry and font are right | Operating system and Chrome version; on Windows, also consider Chrome’s version-specific text contrast change |
| Only some text looks different | Whether those characters are covered by a different font, such as a fallback for missing glyphs |
Keep the page content constant while investigating. Record the CSS viewport, PNG pixel dimensions, window.devicePixelRatio, rendered font, whether its web-font request succeeded, operating system, Chrome build, Headless implementation, and capture library.
2. Lock screenshot dimensions and scale
Begin with a known geometry. Chrome’s Headless command-line screenshot accepts a fixed --window-size=WIDTH,HEIGHT; the documented example uses 412,892. The screenshot viewport and the output image’s pixel dimensions are related to device scale factor, so check both. Do not resize the image until you have confirmed what Chrome produced.
chrome --headless --screenshot --window-size=412,892 https://example.com/
For a baseline comparison, use device scale factor 1. Then test the factor your target environment needs, keeping every other input fixed. Check the resulting PNG dimensions and, in an automated page, inspect window.devicePixelRatio. A scale factor affects screenshot scaling and browser-visible device-pixel-ratio behavior; it is not a universal text-sharpness switch. The --force-device-scale-factor behavior documented in a ChromeDriver issue was specific to that report, so treat it as a clue to test deliberately rather than a blanket fix.
Current Chrome Headless supports configurable virtual screens, including scale factor. The documented virtual-screen configuration starts in stable Chrome with version 142. If your version supports it, record the virtual screen configuration along with the viewport. [Chrome Headless virtual screens](https://developer.chrome.com/docs/automation-and-testing/headless-screen-config/)
Compare like with like: a CSS viewport of 412 × 892 at device scale factor 1 should not be visually judged against a 412 × 892 viewport captured at a different scale and then resized. Resampling can soften glyph edges even when Chrome rendered them correctly.
3. Verify the font Chrome actually rendered
A CSS font-family declaration is a preference list, not proof that the first named font is in use. In Chrome DevTools, inspect the element and look under Computed for Rendered Fonts. This identifies the typeface used by Chrome’s text-rendering layer and can reveal when a web font fell back to a system font. [Chrome DevTools: rendered fonts](https://developer.chrome.com/docs/devtools/rendering/apply-effects/)
Then check the font resource in the Network panel: did the expected file load successfully, and is the element using the expected face and weight? A request can fail because of a bad URL, a blocked cross-origin request, a server error, or an unavailable resource. A page may also render before the font becomes ready.
For a controlled capture in Puppeteer, wait for the document’s font set before taking the screenshot:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 412, height: 892, deviceScaleFactor: 1 });
await page.goto('https://example.com/', { waitUntil: 'networkidle2' });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: 'shot.png' });
} finally {
await browser.close();
}
document.fonts.ready is a useful application-level wait for fonts the document has requested. It does not prove that the intended face loaded: inspect the rendered font and check the request. Likewise, networkidle2 is a navigation condition, not a guarantee that every page’s required font is ready.
Chrome’s command-line --timeout sets a maximum wait before capture; it can be useful when content is still loading, but a longer timeout is not proof of font readiness. Choose a wait condition that matches the page and verify the result. [Chrome Headless command-line reference](https://developer.chrome.com/docs/automation-and-testing/headless-cli/)
Understand fallback during web-font loading
With font-display: swap, optional, or fallback, a system font can be displayed while a custom font is unavailable. When the custom face replaces that fallback, glyph shapes and metrics can change. This can make two captures of the same page differ if they happen at different points in the font-loading process. [Chrome Lighthouse: font display](https://developer.chrome.com/docs/lighthouse/performance/font-display/)
@font-face {
font-family: "Site Sans";
src: url("/fonts/site-sans.woff2") format("woff2");
font-style: normal;
font-weight: 400;
font-display: swap;
}
body {
font-family: "Site Sans", Arial, sans-serif;
}
font-display controls what users see while a custom font loads; it does not make the final font rasterize more sharply. If stable screenshots matter, make the intended font available in the capture environment and wait for it before capturing.
4. Compare operating systems and Chrome versions
If the pixel dimensions and actual font are correct, compare the same page in the same Chrome build and capture setup across the environments in question. Text rasterization can differ by platform and browser version. Do not assume a result from Windows applies to Linux or macOS.
For Windows specifically, Chrome’s explanation of text rendering describes how Chromium’s Skia rasterization, contrast, and gamma settings contributed to reports of washed-out text. The revised contrast value became the default for Windows Chrome builds starting with Chrome 132. This is useful context when comparing Windows captures across versions; it is not a generic Linux or macOS flag to add. [Chrome: improved text rendering on Windows](https://developer.chrome.com/blog/better-text-rendering-in-chromium-based-browsers-on-windows/)
Also confirm which Headless implementation you run. Since Chrome 132, the old Headless implementation is distributed separately as chrome-headless-shell; --headless runs unified Chrome Headless. A comparison across implementations can introduce differences unrelated to your CSS. [Chrome Headless mode and Headless Shell](https://developer.chrome.com/docs/automation-and-testing/headless/)
5. Use a reproducible Puppeteer capture
This example fixes the viewport and scale, waits for navigation and document fonts, then writes the PNG. Pin the Chrome/Puppeteer versions and use the same operating environment when comparing captures.
import puppeteer from 'puppeteer';
const url = process.argv[2] ?? 'https://example.com/';
const browser = await puppeteer.launch({
headless: true,
// Add only flags required by your environment. Avoid changing rendering flags
// during a baseline comparison.
});
try {
const page = await browser.newPage();
await page.setViewport({
width: 412,
height: 892,
deviceScaleFactor: 1,
});
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60_000 });
await page.evaluate(() => document.fonts.ready);
const renderingInfo = await page.evaluate(() => ({
devicePixelRatio: window.devicePixelRatio,
viewport: { width: innerWidth, height: innerHeight },
fontsStatus: document.fonts.status,
}));
console.log(renderingInfo);
await page.screenshot({ path: 'shot.png' });
} finally {
await browser.close();
}
Install Puppeteer in a project with npm install puppeteer, save this as an ES module, then run node capture.mjs https://example.com/. If the page keeps making requests, network-idle navigation may wait until its timeout; use a page-specific readiness condition when appropriate, while still waiting for the required font.
6. Capture from Python or the command line
The Chrome CLI is the smallest reproducible baseline. Set the timeout intentionally: Chrome captures when the limit is reached even if the page is still loading, so an early capture can miss late content or fonts.
chrome --headless \
--screenshot=shot.png \
--window-size=412,892 \
--timeout=10000 \
https://example.com/
Python can invoke the same installed Chrome binary, keeping the capture path explicit:
import subprocess
url = "https://example.com/"
subprocess.run(
[
"google-chrome",
"--headless",
"--screenshot=shot.png",
"--window-size=412,892",
"--timeout=10000",
url,
],
check=True,
)
Replace google-chrome with the Chrome executable path for your system. This CLI example fixes the viewport but cannot itself wait on a page-specific JavaScript font readiness condition. Use a browser automation API such as the Puppeteer example when that condition is needed.
7. Avoid fixes that do not address the cause
- Do not add
--disable-gpuas a general font-sharpness fix. Chrome’s older Headless documentation presents it in the context of historical platform workarounds, not as a text-quality control. [Chrome Headless Shell documentation](https://developer.chrome.com/docs/automation-and-testing/headless-chrome-shell/) - Do not force GPU rasterization without evidence. First establish whether geometry, the selected font, or platform rasterization explains the difference.
- Do not add text shadows or change font weight to conceal a mismatch. Those alter the page’s appearance and can hide a fallback font or scaling problem.
- Do not resize the PNG before checking its original dimensions. Image resampling can make correct browser output look soft.
8. Troubleshooting common capture differences
| Symptom or error | Likely cause | What to do |
|---|---|---|
| PNG dimensions are larger or smaller than expected | Device scale factor differs from the assumed value, or the image was resized | Set a fixed viewport and scale factor; inspect window.devicePixelRatio and the PNG’s actual pixel dimensions before editing it. |
| Text shape or line breaks differ from the browser | A fallback font, different font weight, missing glyph, or font not ready at capture | Inspect Rendered Fonts, check the font request and face/weight, then wait for document.fonts.ready. |
| Font looks correct locally but wrong in CI | Different installed fonts, Chrome build, operating system, or Headless implementation | Record and align those inputs; bundle or install the required font in the capture environment. |
| Some characters use a visibly different style | The chosen font does not contain those glyphs, so the browser uses another font for them | Check the rendered fonts for the affected text and use a font that covers the required character set. |
| Capture shows fallback text, missing content, or a transient layout | Capture happened before fonts or page content finished loading | Use an explicit readiness condition; a fixed CLI timeout and network-idle event do not guarantee every page resource is ready. |
| Text looks washed out only on a Windows machine | Platform/version-specific rasterization or contrast difference | Compare Chrome versions on the same Windows environment; account for the default contrast change beginning with Windows Chrome 132. |
--headless=old fails to launch |
The old mode was removed from the Chrome binary beginning with Chrome 132 | Use --headless for unified Chrome Headless or the separate chrome-headless-shell binary if you specifically need Headless Shell. |
| Capture ends before the expected font appears | The CLI --timeout elapsed while the page was still loading |
Increase the limit only if needed and prefer an application-level font readiness check in automation. |
9. Keep comparisons reliable and affordable
For a useful regression comparison, keep the URL and page state, CSS viewport, device scale factor, Chrome build, operating system, fonts, and capture implementation constant. Save the original PNGs and metadata before resizing, compressing, or comparing them. If you change one variable at a time, you can tell whether an improvement came from geometry, font readiness, or the rendering environment.
Capture cost and runtime are separate concerns in a self-hosted workflow: longer waits can make jobs slower, while short fixed timeouts can capture incomplete pages. Choose a bounded timeout and a page-specific readiness condition. This evidence does not establish one universally best timeout, platform, or scale factor.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. Make one request for a screenshot, then compare its output with your local capture. Its screenshot options include viewport/device presets and retina scale; consult the API documentation for parameters and response details.
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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', new Uint8Array(await res.arrayBuffer()));
Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. 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 at ScreenshotNeo sign-up.
FAQ
Does Headless Chrome use a different font renderer from regular Chrome?
Current Headless mode is unified with Chrome. If results differ, check the browser version, platform, fonts, scale, and whether the capture uses the separate Headless Shell implementation.
Will a higher device scale factor make text sharper?
It produces a different output scale and can affect pixel dimensions. It is not a universal sharpness fix; compare the target scale against a fixed baseline and avoid resampling the result.
Does font-display: swap fix screenshot font quality?
No. It lets fallback text appear while a custom font is unavailable. For a deterministic screenshot, make sure the intended face has loaded before capture.
Should I add --disable-gpu?
Not as a general text rendering fix. First identify whether the difference comes from scale, font selection, or the platform and Chrome build.


