How to Load Images with html-to-image on iOS
Fix missing images in html-to-image on iPhone and iPad with CORS-safe loading, readiness checks, retries, SVG fallbacks, and server capture.
Images can be missing or blank when html-to-image captures a DOM node on iPhone or iPad even though the page displays them normally. Safari and WebKit have reported problems with cross-origin images, SVG assets, and the first canvas conversion. Make every image browser-readable, wait for decoding and layout, then capture; keep a retry and a server-side fallback for production output.
The library serializes a DOM node into SVG and draws it through HTML5 canvas. That path depends on image loading, SVG support, canvas security rules, and timing. A successful page render does not guarantee that toCanvas, toPng, or toBlob will contain the same pixels. The project repository describes this DOM-to-image pipeline, while Safari and iOS issue reports document the failure patterns (repository, issue #147, issue #420, issue #488).
Use a readiness-checked capture
This complete example waits for every image in the capture subtree, converts remote images to same-origin blob URLs, and retries a blank first result once.
<script type="module">
import * as htmlToImage from 'https://cdn.jsdelivr.net/npm/html-to-image@1.11.11/+esm';
const node = document.querySelector('#invoice');
function waitForImage(img) {
if (img.complete && img.naturalWidth > 0) return Promise.resolve();
return new Promise((resolve, reject) => {
const done = () => {
cleanup();
img.naturalWidth > 0 ? resolve() : reject(new Error(`Image failed: ${img.src}`));
};
const fail = () => { cleanup(); reject(new Error(`Image failed: ${img.src}`)); };
const cleanup = () => {
img.removeEventListener('load', done);
img.removeEventListener('error', fail);
};
img.addEventListener('load', done, { once: true });
img.addEventListener('error', fail, { once: true });
});
}
async function replaceRemoteImages(root) {
const images = [...root.querySelectorAll('img')];
for (const img of images) {
if (!img.src || img.src.startsWith('data:') || img.src.startsWith('blob:')) continue;
// Set crossorigin before assigning src when you control the image server.
const original = img.src;
try {
const response = await fetch(original, { mode: 'cors', credentials: 'omit' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
img.src = URL.createObjectURL(blob);
await waitForImage(img);
} catch (error) {
console.warn('Keeping original image; conversion may fail in Safari', original, error);
}
}
}
async function waitForImages(root) {
await Promise.all([...root.querySelectorAll('img')].map(waitForImage));
}
async function capture() {
await replaceRemoteImages(node);
await waitForImages(node);
// Allow layout and decoding work to finish before the first canvas draw.
await new Promise(requestAnimationFrame);
await new Promise(requestAnimationFrame);
let dataUrl = await htmlToImage.toPng(node);
if (dataUrl === 'data:,') {
await new Promise(resolve => setTimeout(resolve, 250));
await waitForImages(node);
dataUrl = await htmlToImage.toPng(node);
}
if (dataUrl === 'data:,') throw new Error('html-to-image returned a blank image');
document.querySelector('#result').src = dataUrl;
}
capture().catch(console.error);
</script>
Why iOS captures lose images
- Cross-origin pixels: the image server may omit CORS headers, or Safari may still reject the image during SVG serialization and canvas drawing. Safari issue #147 reports an external image rendered as an empty placeholder even with CORS enabled.
- SVG assets: issue #488 reports SVG images as unsupported on Safari. Use a PNG, JPEG, or WebP fallback for the capture path.
- First-call timing: issue #488 describes blank first
toBlob,toCanvas, andtoPngcalls. Issue #420 reports images skipped on iOS 16 and a background appearing only on a later attempt. - Lazy loading: an image below the viewport may not have loaded when capture starts. Scroll it into view or explicitly wait for its load event.
- CSS backgrounds: a background image is not an
<img>; inspect computed styles and preload its URL separately if it is essential to the output.
Make remote images readable
Prefer same-origin URLs
Proxy assets through your own origin when possible. Same-origin files avoid a second server’s CORS policy and make output more deterministic. Preserve the original content type when your proxy responds so raster decoders receive the expected format.
Set crossorigin before src
const img = document.querySelector('#hero');
img.crossOrigin = 'anonymous';
img.src = 'https://cdn.example.com/hero.png';
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = () => reject(new Error('hero failed to load'));
});
The image server must send an Access-Control-Allow-Origin value that permits your page. This is necessary when you control the deployment, but the Safari reports show that CORS headers alone are not a guarantee.
Convert through a controlled fetch
async function asBlobUrl(url) {
const response = await fetch(url, { mode: 'cors', credentials: 'omit' });
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
const blob = await response.blob();
return URL.createObjectURL(blob);
}
const hero = document.querySelector('#hero');
hero.src = await asBlobUrl('https://cdn.example.com/hero.jpg');
await waitForImage(hero);
This only works when the fetch is permitted by the remote server. Do not attempt to bypass CORS from client JavaScript; use a server you operate when you need a reliable proxy.
Wait for every image and for layout
Check both complete and naturalWidth. A completed request can still represent a broken image. Wait for all descendants, then give the browser a frame or two to apply layout and decode newly assigned blob URLs.
async function waitUntilReady(root) {
const images = [...root.querySelectorAll('img')];
await Promise.all(images.map(async img => {
if (!(img.complete && img.naturalWidth > 0)) {
await waitForImage(img);
}
if (img.decode) {
try { await img.decode(); } catch (_) { /* load succeeded; Safari may reject decode */ }
}
}));
await new Promise(requestAnimationFrame);
}
For lazy images, trigger loading before this function:
for (const img of document.querySelectorAll('#invoice img[loading="lazy"]')) {
img.loading = 'eager';
img.scrollIntoView({ block: 'center' });
}
await waitUntilReady(document.querySelector('#invoice'));
Use raster fallbacks for SVG
Keep SVG for normal page display if needed, but provide a raster source for iOS capture. A <picture> element lets the browser choose a PNG fallback:
<picture>
<source srcset="diagram.svg" type="image/svg+xml">
<img src="diagram.png" alt="Architecture diagram">
</picture>
For a capture-specific clone, select the raster image explicitly and wait for it before calling html-to-image. Issue #488’s SVG observation is an issue report, not an exhaustive compatibility matrix, so verify the exact iOS versions you support.
Retry strategy and fallback rendering
A short delay or bounded retry can help intermittent timing failures. Issue #420 includes a report that a 250 ms delay helped partially, while another report says timing alone was incomplete. Treat retries as a fallback experiment, not a compatibility guarantee.
async function captureWithRetry(root, attempts = 2) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
await waitUntilReady(root);
const canvas = await htmlToImage.toCanvas(root);
if (canvas.width > 0 && canvas.height > 0) return canvas;
throw new Error('empty canvas');
} catch (error) {
lastError = error;
await new Promise(resolve => setTimeout(resolve, 250 * (i + 1)));
}
}
throw lastError;
}
For invoices, reports, and other production-critical output, retain a server-side renderer or alternate capture path. The cited reports establish browser-specific unreliability but do not validate one particular replacement library.
Complete diagnostic checklist
- Confirm the image URL returns a successful response in Safari.
- Check
img.complete,img.naturalWidth, and the computeddisplay,visibility, and dimensions. - Set
crossoriginbeforesrcand verify the server’s CORS response. - Replace remote assets with same-origin files or blob URLs.
- Replace SVG with a PNG, JPEG, or WebP fallback.
- Wait for all images, call
decode()where available, and wait for animation frames. - Capture once, inspect the result, then perform one bounded retry.
- Log the iOS version, Safari version, asset URL, response headers, and html-to-image version for reproducibility.
Troubleshooting common errors
| Symptom | Likely cause | Fix |
|---|---|---|
| Remote image is an empty box | Cross-origin serialization or Safari issue | Use same-origin or blob URL, set CORS before src, then wait for readiness. |
| Only SVG images disappear | Safari SVG support problem | Provide a raster fallback for capture. |
| First capture is blank; second works | Decode or layout race | Await image readiness and two animation frames; retry once after a short delay. |
| Fetch fails before conversion | Remote server blocks CORS or credentials | Use a permitted asset host or proxy through your server. |
| Canvas is tainted | Pixels came from an origin without usable CORS | Do not draw that asset; replace it with a same-origin or server-fetched blob. |
| Lazy image is missing | It never loaded before capture | Switch it to eager loading, scroll it into view, and await its load event. |
Performance, reliability, and privacy
- Converting images to blobs adds network and decoding work. Cache those blobs during a session instead of fetching the same URL for every capture.
- Large full-page nodes require more canvas memory. Capture an element when the user needs only a component, and release object URLs with
URL.revokeObjectURLafter the capture. - Parallel image fetches reduce waiting time but can overload a mobile connection. Use a small concurrency limit for galleries.
- Client capture keeps pixels on the device, but any proxy or remote image host can observe requests. Avoid placing secrets in image URLs.
- Record a failure and preserve the original DOM when capture fails so users can retry or download a server-rendered result.
Or skip the browser setup
ScreenshotNeo provides a website screenshot API at screenshotneo.com. It loads the page on a capture server and returns PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing result. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf.
See the ScreenshotNeo API documentation for options 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}`);
There is a free plan with 1,000 screenshots each month and no card. Paid plans start at $5 for 3,000 screenshots, and every feature is available on every plan. Create a free ScreenshotNeo account.
FAQ
Does adding crossorigin="anonymous" always fix iOS?
No. It is required for many cross-origin setups, but Safari issue reports show failures can remain even when CORS is enabled.
Should I wait a fixed number of milliseconds?
Use readiness events first. A short bounded delay can be a fallback for intermittent timing, but reports do not establish a universal delay.
Can I keep SVG for desktop and rasterize only on iOS?
Yes. Serve a raster source to the capture subtree or use a <picture> fallback, then verify the exact Safari versions you support.
When should capture move to a server?
Move it when output must be deterministic across iOS versions, when remote CORS cannot be controlled, or when a failed client capture has a material cost.


