Why Is My Puppeteer Screenshot Blurry for the Main Flourish Chart but Sharp for Other Webpage Elements?
A blurry Flourish chart with sharp page text usually comes from iframe rendering, device scale, or unfinished chart animation. Fix it with explicit viewport settings and chart-ready capture.
A Flourish chart that looks blurry while surrounding text remains sharp usually has its own rendering context. Flourish visualizations are commonly embedded in an iframe, and the chart may rasterize a canvas or SVG at a different effective device pixel ratio from the outer document. Puppeteer’s default deviceScaleFactor is 1, so set the viewport size and scale before navigation, wait for the chart’s own readiness state and animation to finish, then capture the iframe or chart element directly.
If the visualization supports Flourish’s Live API, a native snapshot can produce PNG, JPEG or SVG at a selected scale. SVG is the sharpest option when you need vector output.
1. What causes selective blur?
When normal HTML text and controls are sharp but one Flourish chart is soft, the browser is usually capturing two different rendering contexts:
- Outer document: text and CSS elements are rendered by the page at the emulated device scale.
- Embedded visualization: an iframe can have its own layout, canvas backing store, SVG dimensions and animation lifecycle.
A screenshot can therefore be technically correct while the chart has fewer source pixels than its displayed CSS size. A chart that is 800 CSS pixels wide but rendered internally at 800 bitmap pixels will look soft in a 2× output that expects 1,600 pixels.
Other common causes are capturing during an animation, taking the screenshot before data-driven layout finishes, scaling the screenshot in an image editor, or capturing the whole page when the chart itself is the deliverable.
2. Diagnose the embed before changing code
- Inspect the page source and DevTools Elements panel. Confirm whether the Flourish visualization is inside an
iframe. - Record the iframe’s CSS bounding box with
getBoundingClientRect(). - Inspect the iframe content frame, if same-origin access is available to Puppeteer, and identify the chart’s stable container or readiness signal.
- Check the final PNG dimensions. They should approximately equal the CSS capture dimensions multiplied by
deviceScaleFactor. - Repeat the capture with a higher scale and with animation fully settled. If only the chart improves, the issue is inside the chart rendering path rather than page-wide CSS.
const box = await page.locator('iframe').first().boundingBox();
console.log(box);
const metrics = await page.locator('iframe').first().evaluate(el => ({
cssWidth: el.getBoundingClientRect().width,
cssHeight: el.getBoundingClientRect().height,
widthAttribute: el.getAttribute('width'),
heightAttribute: el.getAttribute('height')
}));
console.log(metrics);
3. Set the viewport scale before navigation
Set explicit width, height and deviceScaleFactor before calling goto. Puppeteer documents deviceScaleFactor as the emulated device pixel ratio and its default is 1. Changing it after the chart has initialized can leave the visualization laid out for the old scale.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setViewport({
width: 1600,
height: 900,
deviceScaleFactor: 2
});
await page.goto('https://example.com/page-with-flourish', {
waitUntil: 'networkidle2',
timeout: 90000
});
console.log(await page.evaluate(() => ({
devicePixelRatio: window.devicePixelRatio,
innerWidth: window.innerWidth,
innerHeight: window.innerHeight
})));
await browser.close();
Use a scale that matches the output you need. A factor of 2 is a practical starting point; larger values increase memory use, encoded file size and capture time. The scale cannot repair a chart that has already rendered a low-resolution bitmap internally, but it gives the browser enough pixels for the page and iframe to render sharply.
4. Wait for the chart, not only the page
networkidle2 means network activity has quieted according to Puppeteer’s navigation rule. It does not prove that Flourish has finished fetching data, measuring text, laying out marks or animating the first frame. Wait for a selector or application signal that is specific to the visualization.
await page.goto(url, { waitUntil: 'networkidle2', timeout: 90000 });
const iframeHandle = await page.waitForSelector('iframe', {
visible: true,
timeout: 30000
});
const frame = await iframeHandle.contentFrame();
if (!frame) throw new Error('The iframe is not available yet');
// Replace this with a stable signal from the target visualization.
await frame.waitForSelector('[data-ready="true"]', { timeout: 30000 });
// If no stable signal exists, use a bounded fallback for the known animation.
await new Promise(resolve => setTimeout(resolve, 1000));
The [data-ready="true"] selector is illustrative. Flourish templates do not share one universal readiness selector. Inspect the specific visualization and choose a stable element, class, attribute or application event. A fixed delay is only a fallback and is not a guarantee that every chart has finished.
5. Capture the iframe or chart element directly
Puppeteer supports element screenshots. Capturing the intended iframe region avoids scaling a large page down to a thumbnail and makes the output dimensions predictable.
import puppeteer from 'puppeteer';
const url = 'https://example.com/page-with-flourish';
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.setViewport({
width: 1600,
height: 900,
deviceScaleFactor: 2
});
await page.goto(url, {
waitUntil: 'networkidle2',
timeout: 90000
});
const iframeHandle = await page.waitForSelector('iframe', {
visible: true,
timeout: 30000
});
const frame = await iframeHandle.contentFrame();
if (!frame) throw new Error('Could not access the Flourish iframe');
// Use the actual stable selector discovered in DevTools.
await frame.waitForSelector('[data-ready="true"]', { timeout: 30000 });
await iframeHandle.screenshot({
path: 'flourish-chart.png',
type: 'png'
});
await browser.close();
To capture a chart container inside the iframe, locate it through the frame and call screenshot on that element:
const chart = await frame.waitForSelector('.chart-container', {
visible: true,
timeout: 30000
});
await chart.screenshot({ path: 'chart-only.png' });
Cross-origin iframe policies can prevent DOM access to the frame. In that case, capture the iframe element from the parent page, or capture a known rectangle with page.screenshot({ clip }).
6. Compare browser capture with Flourish export
Browser capture preserves the page composition, surrounding labels and responsive layout. Flourish’s Live API snapshot method is a better fit when you need only the visualization and the project permits API export. The documented formats are PNG, JPEG and SVG, and the scale option increases generated resolution.
- PNG: good for raster workflows and transparency where supported.
- JPEG: smaller files for photographic or non-transparent output.
- SVG: preferred for vector sharpness when the visualization and downstream tools support it.
Use the Live API when chart-only fidelity matters more than the exact browser page. Use Puppeteer when you need the chart embedded in its real page, with the page’s CSS, surrounding content and responsive state.
See the Flourish developer documentation for the snapshot method and the Puppeteer viewport documentation for device scale behavior.
7. A complete reusable capture script
import puppeteer from 'puppeteer';
const targetUrl = process.argv[2];
if (!targetUrl) {
console.error('Usage: node capture-flourish.mjs https://example.com/chart-page');
process.exit(1);
}
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const page = await browser.newPage();
await page.setViewport({
width: 1600,
height: 900,
deviceScaleFactor: 2
});
await page.goto(targetUrl, {
waitUntil: 'networkidle2',
timeout: 90000
});
const iframe = await page.waitForSelector('iframe', {
visible: true,
timeout: 30000
});
const frame = await iframe.contentFrame();
if (!frame) throw new Error('Flourish iframe content is unavailable');
// Replace this selector with a real signal from your visualization.
try {
await frame.waitForSelector('[data-ready="true"]', { timeout: 15000 });
} catch {
// Bounded fallback only when the visualization has no inspectable ready signal.
await new Promise(resolve => setTimeout(resolve, 1000));
}
const bounds = await iframe.boundingBox();
if (!bounds || bounds.width < 1 || bounds.height < 1) {
throw new Error('The Flourish iframe has no visible bounds');
}
await iframe.screenshot({
path: 'flourish.png',
type: 'png'
});
console.log(JSON.stringify({
output: 'flourish.png',
cssBounds: bounds,
expectedPixels: {
width: Math.round(bounds.width * 2),
height: Math.round(bounds.height * 2)
}
}, null, 2));
} finally {
await browser.close();
}
Install and run it with:
npm install puppeteer
node capture-flourish.mjs https://example.com/chart-page
8. Troubleshooting checklist
| Symptom | Likely cause | Fix |
|---|---|---|
| Only the chart is blurry | Iframe or chart backing store uses a lower effective scale | Set width, height and deviceScaleFactor before navigation; capture the iframe directly. |
| Text is sharp but bars or lines are soft | Canvas was rasterized at fewer pixels than its CSS size | Increase device scale, avoid post-capture enlargement, or use Flourish SVG export. |
| First frame is incomplete | Screenshot taken during data loading or animation | Wait for a chart-specific selector or readiness event; use a bounded delay only as fallback. |
contentFrame() returns null |
Iframe has not loaded or was replaced | Wait for the iframe, then retry after its load; verify the selector points to the actual Flourish frame. |
| Cannot find an element inside the frame | Wrong selector, cross-origin restrictions or template variation | Inspect the target template, use the parent iframe screenshot, or capture a clip rectangle. |
| Output dimensions are smaller than expected | Viewport or element CSS dimensions were not multiplied by the intended scale | Log devicePixelRatio, bounding-box dimensions and the PNG pixel dimensions. |
| Chart changes between runs | Responsive layout, animation timing or data updates | Use fixed viewport dimensions, deterministic data and a stable readiness condition. |
| Browser crashes at high scale | Large viewport, many pages or excessive bitmap memory | Lower concurrency, reduce the viewport, capture only the chart, or use SVG export. |
9. Performance, reliability and cost considerations
- Scale: Increasing
deviceScaleFactorincreases pixel count roughly with the square of the factor. A 2× width and 2× height output contains about four times as many pixels as 1×. - Memory: Full-page captures and large iframes can consume substantial browser memory. Capture the chart element when page context is unnecessary.
- Concurrency: Reuse a browser process where appropriate, but isolate pages and cap concurrent captures so animations and resource loading do not compete.
- Readiness: Prefer a semantic ready signal over arbitrary sleeps. Record timeout, selector and final dimensions so failures are diagnosable.
- Retries: Retry navigation and transient resource failures with a limit. Do not treat a successful HTTP response as proof that the visualization is ready.
- Output choice: Use SVG for scalable chart artwork, PNG for predictable raster output and JPEG only when its compression tradeoff is acceptable.
A browser screenshot has no per-image API fee, but it does require browser infrastructure, dependency maintenance and enough CPU and memory for the chosen scale. Native export can reduce browser work when the visualization exposes it.
10. Or skip the browser setup
ScreenshotNeo provides a one-request website screenshot API that returns PNG, JPEG, WebP or PDF. It can capture a page without maintaining Puppeteer, and its options include custom viewport and device settings, element capture, waits, custom JavaScript and CSS, headers, cookies, caching and bulk jobs. See the ScreenshotNeo API documentation for the full parameter list.
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}`);
Cookie banners, newsletter popups and chat widgets are removed before the shot. Bot checks, blank pages and failed loads are never billed, and the response reports the page verdict and billing status in X-Page-Verdict and X-Billed headers. ScreenshotNeo also offers an MCP server so Claude, Cursor and other MCP clients can take screenshots, inspect pages and capture PDFs. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
11. FAQ
Does a Flourish iframe ignore deviceScaleFactor?
It does not necessarily ignore it, but the iframe’s internal canvas or SVG can still be laid out at a different effective resolution. Set the scale before navigation and verify the final pixel dimensions.
Should I always use deviceScaleFactor: 2?
No. Choose the smallest scale that meets your output requirement and memory budget. Start at 2× for high-resolution raster output, then compare the actual chart pixels.
Is waiting for networkidle2 enough?
No. Network-idle navigation does not prove that chart data, layout or animation has completed. Use a chart-specific readiness signal.
When is SVG better than Puppeteer PNG?
Use SVG when the Live API is available and you need scalable vector output. Use Puppeteer when the chart must appear exactly as embedded in its surrounding page.
Why is the chart sharp in the browser but blurry in the saved file?
The browser may be displaying a scaled preview while the saved capture uses a lower backing-store resolution. Compare CSS bounds, device pixel ratio and file dimensions before changing chart styles.


