Advanced Guide
Learn how to generate PDFs from HTML with dynamic, asynchronous content like QR codes, charts, and API data. Master the window.__RENDER_DONE__ pattern for reliable PDF generation with CustomJS and Puppeteer.
When generating PDFs from HTML that contains dynamic JavaScript content (QR codes, charts, API calls, animations), the biggest challenge is: How do you know when the page is ready to capture?
Traditional approaches like DOMContentLoaded or window.onload don't work because they fire before async JavaScript completes. This guide shows you the professional solution used by CustomJS and how to implement it yourself.
Consider this common scenario - generating an invoice PDF with a payment QR code:
<!-- ❌ This FAILS - PDF captures before QR code renders -->
<script src="https://cdn.jsdelivr.net/npm/qrcode/build/qrcode.min.js"></script>
<script>
QRCode.toCanvas(canvas, 'payment-url', () => {
console.log('QR ready!'); // Too late - PDF already captured
});
</script>Why this fails:
DOMContentLoadedwindow.__RENDER_DONE__ Flag CustomJS (and modern Puppeteer setups) support a special flag that signals when async content is ready:
The Pattern:
window.__RENDER_DONE__ = false EARLY in <head>window.__RENDER_DONE__ = true when completetrue before capturing<!DOCTYPE html>
<html>
<head>
<script>
// Step 1: Signal that async content is loading
window.__RENDER_DONE__ = false;
</script>
</head>
<body>
<canvas id="qr-canvas"></canvas>
<script type="module">
import QRCode from "https://esm.sh/qrcode@1.5.4";
// Step 2: Generate QR code
const canvas = document.getElementById('qr-canvas');
await QRCode.toCanvas(canvas, 'https://customjs.io', {
width: 200
});
// Step 3: Signal completion
window.__RENDER_DONE__ = true;
console.log('Ready for PDF capture!');
</script>
</body>
</html> When you use CustomJS's HTML to PDF API, it automatically checks for the __RENDER_DONE__ flag:
// CustomJS internal logic (simplified)
await page.goto(url);
// Check if page uses __RENDER_DONE__ flag
const usesRenderFlag = await page.evaluate(() => {
return typeof window.__RENDER_DONE__ !== 'undefined';
});
if (usesRenderFlag) {
// Wait for flag to become true (max 30 seconds)
await page.waitForFunction(
() => window.__RENDER_DONE__ === true,
{ timeout: 30000 }
);
}
// Now capture PDF
const pdf = await page.pdf();Key behaviors:
__RENDER_DONE__ is not defined → captures immediately (normal behavior)__RENDER_DONE__ = false → waits for it to become true__RENDER_DONE__ = true → captures immediately<script>
window.__RENDER_DONE__ = false;
</script>
<script type="module">
import QRCode from "https://esm.sh/qrcode@1.5.4";
// Generate multiple QR codes
const codes = [
{ id: 'qr1', data: 'https://example.com/ticket/1' },
{ id: 'qr2', data: 'https://example.com/ticket/2' },
{ id: 'qr3', data: 'https://example.com/ticket/3' }
];
await Promise.all(
codes.map(({ id, data }) => {
const canvas = document.getElementById(id);
return QRCode.toCanvas(canvas, data, { width: 150 });
})
);
window.__RENDER_DONE__ = true;
</script><script>
window.__RENDER_DONE__ = false;
</script>
<script type="module">
import Chart from 'https://esm.sh/chart.js/auto';
// Fetch data from API
const response = await fetch('https://api.example.com/sales');
const data = await response.json();
// Render chart
const ctx = document.getElementById('chart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: data,
options: {
animation: {
onComplete: () => {
// Chart animation finished
window.__RENDER_DONE__ = true;
}
}
}
});
</script><script>
window.__RENDER_DONE__ = false;
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<script>
// Wait for fonts to load
document.fonts.ready.then(() => {
window.__RENDER_DONE__ = true;
console.log('Fonts loaded!');
});
</script>| Feature | CustomJS | Standard Puppeteer |
|---|---|---|
| __RENDER_DONE__ Support | ✓ Built-in | ✗ Manual implementation |
| Setup Required | None - just use the flag | Custom waitForFunction code |
| Infrastructure | Managed (serverless) | Self-hosted required |
| Timeout Handling | Automatic (30s default) | Manual configuration |
| Error Handling | Built-in retry logic | Custom implementation |
If you're using Puppeteer directly, here's how to implement the same pattern:
const puppeteer = require('puppeteer');
async function generatePDF(html) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html);
// Check if page uses __RENDER_DONE__ flag
const usesRenderFlag = await page.evaluate(() => {
return typeof window.__RENDER_DONE__ !== 'undefined';
});
if (usesRenderFlag) {
console.log('Waiting for __RENDER_DONE__ flag...');
try {
await page.waitForFunction(
() => window.__RENDER_DONE__ === true,
{ timeout: 30000 }
);
console.log('Content ready!');
} catch (error) {
console.error('Timeout waiting for __RENDER_DONE__');
throw error;
}
}
const pdf = await page.pdf({
format: 'A4',
printBackground: true
});
await browser.close();
return pdf;
}
// Usage
const html = `
<!DOCTYPE html>
<html>
<head>
<script>window.__RENDER_DONE__ = false;</script>
</head>
<body>
<canvas id="qr"></canvas>
<script type="module">
import QRCode from "https://esm.sh/qrcode@1.5.4";
await QRCode.toCanvas(document.getElementById('qr'), 'data');
window.__RENDER_DONE__ = true;
</script>
</body>
</html>
`;
const pdf = await generatePDF(html);__RENDER_DONE__ = false in <head> before any other scriptstype="module") for reliable async/awaittrue only after ALL async content is readytrue even on errors to prevent hanging<head>true - this causes timeoutssrc - use ES modules insteadDOMContentLoaded or window.onload aloneSymptom: PDF generation times out after 30 seconds
Cause: JavaScript error prevents flag from being set
Solution: Always use try/catch and set flag in finally block
try {
await generateQRCode();
} catch (error) {
console.error('QR generation failed:', error);
} finally {
window.__RENDER_DONE__ = true; // Always set!
}Symptom: PDF missing content that should be there
Cause: Flag set before all async operations complete
Solution: Use Promise.all for multiple async operations
await Promise.all([
generateQRCode1(),
generateQRCode2(),
loadChartData()
]);
window.__RENDER_DONE__ = true;Symptom: "Refused to execute script" errors
Cause: CDN serves wrong Content-Type in headless browsers
Solution: Use ES modules from esm.sh instead
<!-- ❌ Fails -->
<script src="https://cdn.jsdelivr.net/npm/qrcode/build/qrcode.min.js"></script>
<!-- ✅ Works -->
<script type="module">
import QRCode from "https://esm.sh/qrcode@1.5.4";
</script>Complete guide with working examples for invoices, tickets, and certificates
Professional invoice templates with dynamic content
Skip the infrastructure setup and use CustomJS's HTML to PDF API with built-in __RENDER_DONE__ support. No servers, no Puppeteer configuration, just reliable PDF generation.