Advanced Guide

HTML to PDF with Async JavaScript Execution: Complete 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.

The Problem: Async Content in PDF Generation

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:

  • ✗ Puppeteer/CustomJS captures the page immediately after DOMContentLoaded
  • ✗ QR code generation is asynchronous and takes 50-200ms
  • ✗ Result: PDF contains empty canvas or broken image

The Solution: window.__RENDER_DONE__ Flag

CustomJS (and modern Puppeteer setups) support a special flag that signals when async content is ready:

The Pattern:

  1. 1. Set window.__RENDER_DONE__ = false EARLY in <head>
  2. 2. Generate your async content (QR codes, charts, API calls)
  3. 3. Set window.__RENDER_DONE__ = true when complete
  4. 4. CustomJS/Puppeteer waits for true before capturing

✅ Working Example: QR Code in PDF

<!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>

How CustomJS Waits for Async Content

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:

  • ✓ If __RENDER_DONE__ is not defined → captures immediately (normal behavior)
  • ✓ If __RENDER_DONE__ = false → waits for it to become true
  • ✓ If __RENDER_DONE__ = true → captures immediately
  • ✓ Timeout after 30 seconds to prevent hanging

Real-World Use Cases

1. Multiple QR Codes

<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>

2. API Data + Chart Rendering

<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>

3. Web Fonts Loading

<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>

CustomJS vs Standard Puppeteer

FeatureCustomJSStandard Puppeteer
__RENDER_DONE__ Support✓ Built-in✗ Manual implementation
Setup RequiredNone - just use the flagCustom waitForFunction code
InfrastructureManaged (serverless)Self-hosted required
Timeout HandlingAutomatic (30s default)Manual configuration
Error HandlingBuilt-in retry logicCustom implementation

Implementing in Standard Puppeteer

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);

Best Practices

✓ DO

  • • Set __RENDER_DONE__ = false in <head> before any other scripts
  • • Use ES modules (type="module") for reliable async/await
  • • Set flag to true only after ALL async content is ready
  • • Add error handling - set true even on errors to prevent hanging
  • • Test with slow network conditions

✗ DON'T

  • • Don't set the flag in the body - it must be in <head>
  • • Don't forget to set it to true - this causes timeouts
  • • Don't use CDN scripts with src - use ES modules instead
  • • Don't rely on DOMContentLoaded or window.onload alone
  • • Don't set multiple flags - use one global flag for all async content

Common Pitfalls & Solutions

❌ Pitfall: Flag never becomes true

Symptom: 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!
}

❌ Pitfall: Flag set too early

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;

❌ Pitfall: MIME type errors with CDN scripts

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>

Related Resources

Try CustomJS

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.

View PDF API Documentation →