reportgen
Back to all posts

Async PDF Workflows: Build It Right the First Time šŸ”„

April 22, 2025

You're generating a 50-page report with 30 charts. It takes 8 seconds.

If you're waiting synchronously for that response, you're doing it wrong.

Your API times out. Your users stare at loading spinners. Your monitoring alerts fire. Everything sucks.

The solution? Async PDF generation.

This guide shows you how to build it properly - with job queues, status polling, retry logic, error recovery, and real production patterns.


Why Async?

The Synchronous Problem

// āŒ This will timeout for large PDFs
app.post('/generate-report', async (req, res) => {
  const pdf = await generatePDFSync(reportData); // Takes 8+ seconds
  res.json({ pdf_url: pdf.url });
  // User waited 8 seconds for a response
  // API gateway probably timed out at 30s
});

The Async Solution

// āœ… Returns immediately, poll for status
app.post('/generate-report', async (req, res) => {
  const jobId = await generatePDFAsync(reportData);
 
  res.json({
    job_id: jobId,
    status: 'processing',
    status_url: `/pdf-status/${jobId}`
  });
  // User gets response in < 100ms
  // Poll status endpoint to check when ready
});

Benefits:

  • āœ… Instant API responses (< 100ms)
  • āœ… No timeouts, ever
  • āœ… Handle large PDFs gracefully
  • āœ… Better user experience
  • āœ… Scales effortlessly

Setting Up Async PDF Generation

Step 1: Trigger Async Generation

import fetch from 'node-fetch';
 
async function generatePDFAsync(template, data) {
  const response = await fetch('https://reportgen.io/api/v1/generate-pdf-async', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': process.env.REPORTGEN_API_KEY
    },
    body: JSON.stringify({
      html_template: template,
      data: data,
      engine: 'handlebars'
    })
  });
 
  const result = await response.json();
  return result.job_id;
}

Step 2: Store Job State

Don't just fire and forget. Track the job.

import { db } from './database';
 
async function createPDFJob(userId, reportType, jobId) {
  await db.pdfJobs.insert({
    id: jobId,
    user_id: userId,
    report_type: reportType,
    status: 'processing',
    created_at: new Date(),
    completed_at: null,
    pdf_url: null,
    error: null
  });
}
 
// Full flow
app.post('/generate-report', async (req, res) => {
  const { reportType, data } = req.body;
  const userId = req.user.id;
 
  // Generate PDF async
  const jobId = await generatePDFAsync(template, data);
 
  // Store job state
  await createPDFJob(userId, reportType, jobId);
 
  res.json({
    job_id: jobId,
    status: 'processing',
    status_url: `/pdf-status/${jobId}`
  });
});

Status Polling

Status Check Endpoint

app.get('/pdf-status/:jobId', async (req, res) => {
  const { jobId } = req.params;
 
  // Check reportgen.io for job status
  const response = await fetch(`https://reportgen.io/api/v1/jobs/${jobId}`, {
    headers: {
      'X-API-Key': process.env.REPORTGEN_API_KEY
    }
  });
 
  const jobStatus = await response.json();
 
  // Update our database
  if (jobStatus.status === 'completed' || jobStatus.status === 'failed') {
    await db.pdfJobs.update(
      { id: jobId },
      {
        status: jobStatus.status,
        pdf_url: jobStatus.pdf_url,
        error: jobStatus.error,
        completed_at: new Date()
      }
    );
  }
 
  res.json({
    job_id: jobId,
    status: jobStatus.status,
    pdf_url: jobStatus.pdf_url,
    error: jobStatus.error,
    progress: jobStatus.progress
  });
});

Client-Side Polling

async function pollForPDF(jobId, maxAttempts = 60) {
  for (let i = 0; i < maxAttempts; i++) {
    const response = await fetch(`/pdf-status/${jobId}`);
    const job = await response.json();
 
    if (job.status === 'completed') {
      return job.pdf_url;
    }
 
    if (job.status === 'failed') {
      throw new Error(job.error);
    }
 
    // Wait 2 seconds before next poll
    await sleep(2000);
  }
 
  throw new Error('PDF generation timeout');
}
 
function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}
 
// Usage
const jobId = await generatePDF(data);
const pdfUrl = await pollForPDF(jobId);
console.log('PDF ready:', pdfUrl);

Background Job Processing

Using Bull Queue

import Bull from 'bull';
 
const pdfQueue = new Bull('pdf-generation', {
  redis: { host: 'localhost', port: 6379 }
});
 
// Producer: Add job to queue
app.post('/generate-report', async (req, res) => {
  const job = await pdfQueue.add({
    userId: req.user.id,
    reportType: req.body.reportType,
    data: req.body.data
  });
 
  res.json({
    job_id: job.id,
    status: 'queued',
    status_url: `/pdf-status/${job.id}`
  });
});
 
// Consumer: Process jobs
pdfQueue.process(async (job) => {
  const { userId, reportType, data } = job.data;
 
  try {
    // Generate PDF
    const pdf = await generatePDFSync(template, data);
 
    // Upload to S3
    const pdfUrl = await uploadToS3(pdf, `reports/${job.id}.pdf`);
 
    // Update job in database
    await db.pdfJobs.update(
      { id: job.id },
      {
        status: 'completed',
        pdf_url: pdfUrl,
        completed_at: new Date()
      }
    );
 
    // Update progress
    job.progress(100);
 
    return { pdf_url: pdfUrl };
 
  } catch (err) {
    await db.pdfJobs.update(
      { id: job.id },
      {
        status: 'failed',
        error: err.message,
        completed_at: new Date()
      }
    );
 
    throw err;
  }
});
 
// Handle completion
pdfQueue.on('completed', async (job, result) => {
  await notifyUser(job.data.userId, {
    message: 'Report ready!',
    pdf_url: result.pdf_url
  });
});
 
// Handle failures
pdfQueue.on('failed', async (job, err) => {
  await notifyUser(job.data.userId, {
    message: 'Report generation failed',
    error: err.message
  });
});

Handling Failures and Retries

Pattern 1: Exponential Backoff Retry

async function generatePDFWithRetry(template, data, maxRetries = 3) {
  let attempt = 0;
 
  while (attempt < maxRetries) {
    try {
      const jobId = await generatePDFAsync(template, data);
      return jobId;
 
    } catch (err) {
      attempt++;
 
      if (attempt >= maxRetries) {
        throw new Error(`PDF generation failed after ${maxRetries} attempts: ${err.message}`);
      }
 
      // Exponential backoff: 1s, 2s, 4s, 8s...
      const delayMs = Math.pow(2, attempt) * 1000;
      console.log(`Retry attempt ${attempt} after ${delayMs}ms`);
      await sleep(delayMs);
    }
  }
}
 
function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

Pattern 2: Dead Letter Queue for Failed Jobs

async function handleFailedPDF(jobId, error) {
  // Move to dead letter queue for manual review
  await db.deadLetterQueue.insert({
    job_id: jobId,
    error: error,
    created_at: new Date(),
    retry_count: 0
  });
 
  // Alert ops team
  await alerting.notify({
    severity: 'warning',
    message: `PDF job ${jobId} failed and moved to DLQ`,
    error: error
  });
}
 
// Check job status periodically
async function checkJobStatus(jobId) {
  const response = await fetch(`https://reportgen.io/api/v1/jobs/${jobId}`, {
    headers: { 'X-API-Key': process.env.REPORTGEN_API_KEY }
  });
 
  const job = await response.json();
 
  if (job.status === 'failed') {
    await handleFailedPDF(jobId, job.error);
  }
 
  return job;
}

Pattern 3: Automatic Retry from DLQ

// Cron job to retry failed PDFs
async function retryFailedPDFs() {
  const failedJobs = await db.deadLetterQueue.find({
    retry_count: { $lt: 3 },
    created_at: { $gt: new Date(Date.now() - 24 * 60 * 60 * 1000) } // Last 24h
  });
 
  for (const job of failedJobs) {
    try {
      const originalJob = await db.pdfJobs.findOne({ id: job.job_id });
 
      // Retry PDF generation
      const newJobId = await generatePDFAsync(
        originalJob.template,
        originalJob.data
      );
 
      // Update DLQ entry
      await db.deadLetterQueue.update(
        { job_id: job.job_id },
        { $inc: { retry_count: 1 }, last_retry: new Date() }
      );
 
    } catch (err) {
      console.error(`Retry failed for job ${job.job_id}:`, err);
    }
  }
}
 
// Run every hour
setInterval(retryFailedPDFs, 60 * 60 * 1000);

Real-Time Updates with Server-Sent Events

Real-time updates without constant polling.

Server-Side SSE Implementation

app.get('/pdf-events/:jobId', async (req, res) => {
  const { jobId } = req.params;
 
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
 
  // Send initial status
  const job = await db.pdfJobs.findOne({ id: jobId });
  res.write(`data: ${JSON.stringify({ status: job.status })}\n\n`);
 
  // Poll reportgen.io and push updates
  const intervalId = setInterval(async () => {
    try {
      const response = await fetch(`https://reportgen.io/api/v1/jobs/${jobId}`, {
        headers: { 'X-API-Key': process.env.REPORTGEN_API_KEY }
      });
 
      const jobStatus = await response.json();
 
      // Send update to client
      res.write(`data: ${JSON.stringify(jobStatus)}\n\n`);
 
      // If completed or failed, stop polling
      if (jobStatus.status === 'completed' || jobStatus.status === 'failed') {
        clearInterval(intervalId);
        res.end();
 
        // Update database
        await db.pdfJobs.update(
          { id: jobId },
          {
            status: jobStatus.status,
            pdf_url: jobStatus.pdf_url,
            error: jobStatus.error,
            completed_at: new Date()
          }
        );
      }
    } catch (err) {
      console.error('Error polling job status:', err);
      clearInterval(intervalId);
      res.end();
    }
  }, 2000); // Poll every 2 seconds
 
  req.on('close', () => {
    clearInterval(intervalId);
  });
});

Client-Side SSE

const eventSource = new EventSource(`/pdf-events/${jobId}`);
 
eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('PDF status:', data.status);
 
  if (data.status === 'completed') {
    console.log('PDF ready:', data.pdf_url);
    eventSource.close();
  } else if (data.status === 'failed') {
    console.error('PDF failed:', data.error);
    eventSource.close();
  }
};

WebSocket Updates

import { Server } from 'socket.io';
 
const io = new Server(httpServer);
 
io.on('connection', (socket) => {
  socket.on('subscribe-pdf', async ({ jobId }) => {
    // Send initial status
    const job = await db.pdfJobs.findOne({ id: jobId });
    socket.emit('pdf-update', { status: job.status });
 
    // Poll for updates
    const intervalId = setInterval(async () => {
      const response = await fetch(`https://reportgen.io/api/v1/jobs/${jobId}`, {
        headers: { 'X-API-Key': process.env.REPORTGEN_API_KEY }
      });
 
      const jobStatus = await response.json();
      socket.emit('pdf-update', jobStatus);
 
      if (jobStatus.status === 'completed' || jobStatus.status === 'failed') {
        clearInterval(intervalId);
 
        await db.pdfJobs.update(
          { id: jobId },
          {
            status: jobStatus.status,
            pdf_url: jobStatus.pdf_url,
            completed_at: new Date()
          }
        );
      }
    }, 2000);
 
    socket.on('disconnect', () => clearInterval(intervalId));
  });
});
 
// Client
const socket = io();
 
socket.emit('subscribe-pdf', { jobId });
 
socket.on('pdf-update', (data) => {
  console.log('PDF status:', data.status);
 
  if (data.status === 'completed') {
    console.log('PDF ready:', data.pdf_url);
  }
});

Monitoring and Observability

Track Key Metrics

import { metrics } from './monitoring';
 
async function checkAndUpdateJobStatus(jobId) {
  const response = await fetch(`https://reportgen.io/api/v1/jobs/${jobId}`, {
    headers: { 'X-API-Key': process.env.REPORTGEN_API_KEY }
  });
 
  const jobStatus = await response.json();
 
  if (jobStatus.status === 'completed' || jobStatus.status === 'failed') {
    const job = await db.pdfJobs.findOne({ id: jobId });
 
    // Calculate generation time
    const generationTime = new Date() - job.created_at;
 
    // Record metrics
    metrics.histogram('pdf.generation.duration', generationTime, {
      status: jobStatus.status,
      report_type: job.report_type
    });
 
    metrics.counter('pdf.generation.completed', 1, {
      status: jobStatus.status
    });
 
    if (jobStatus.status === 'failed') {
      metrics.counter('pdf.generation.failed', 1, {
        error_type: jobStatus.error
      });
    }
 
    // Update database
    await db.pdfJobs.update(
      { id: jobId },
      {
        status: jobStatus.status,
        pdf_url: jobStatus.pdf_url,
        error: jobStatus.error,
        completed_at: new Date()
      }
    );
  }
 
  return jobStatus;
}

Alert on Anomalies

// Alert if generation time exceeds threshold
if (generationTime > 30000) { // 30 seconds
  await alerting.notify({
    severity: 'warning',
    message: `PDF generation took ${generationTime}ms for job ${jobId}`,
    job_id: jobId,
    report_type: job.report_type
  });
}
 
// Alert if failure rate is high
const recentJobs = await db.pdfJobs.find({
  created_at: { $gt: new Date(Date.now() - 60 * 60 * 1000) }
});
 
const failureRate = recentJobs.filter(j => j.status === 'failed').length / recentJobs.length;
 
if (failureRate > 0.05) { // 5% failure rate
  await alerting.notify({
    severity: 'critical',
    message: `PDF failure rate is ${(failureRate * 100).toFixed(2)}%`,
    window: '1 hour'
  });
}

Complete Production-Ready Example

import express from 'express';
import { db } from './database';
import { notifyUser } from './notifications';
 
const app = express();
 
// Generate PDF async
app.post('/generate-report', async (req, res) => {
  try {
    const { reportType, data } = req.body;
    const userId = req.user.id;
 
    const jobId = await generatePDFAsync(
      getTemplate(reportType),
      data
    );
 
    await db.pdfJobs.insert({
      id: jobId,
      user_id: userId,
      report_type: reportType,
      status: 'processing',
      created_at: new Date()
    });
 
    res.json({
      job_id: jobId,
      status: 'processing',
      status_url: `/pdf-status/${jobId}`
    });
 
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});
 
// Status check endpoint
app.get('/pdf-status/:jobId', async (req, res) => {
  try {
    const jobId = req.params.jobId;
 
    // Check reportgen.io status
    const response = await fetch(`https://reportgen.io/api/v1/jobs/${jobId}`, {
      headers: { 'X-API-Key': process.env.REPORTGEN_API_KEY }
    });
 
    const jobStatus = await response.json();
 
    // Update our database if completed/failed
    if (jobStatus.status === 'completed' || jobStatus.status === 'failed') {
      await db.pdfJobs.update(
        { id: jobId },
        {
          status: jobStatus.status,
          pdf_url: jobStatus.pdf_url,
          error: jobStatus.error,
          completed_at: new Date()
        }
      );
 
      // Notify user
      if (jobStatus.status === 'completed') {
        const job = await db.pdfJobs.findOne({ id: jobId });
        await notifyUser(job.user_id, {
          message: 'Your report is ready!',
          pdf_url: jobStatus.pdf_url
        });
      }
    }
 
    res.json({
      job_id: jobId,
      status: jobStatus.status,
      pdf_url: jobStatus.pdf_url,
      error: jobStatus.error,
      progress: jobStatus.progress
    });
 
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});
 
app.listen(3000);

Final Thoughts

Async workflows aren't optional for production PDF generation. They're essential.

Build it right:

  • āœ… Use async for anything > 2 seconds
  • āœ… Implement status polling
  • āœ… Add retry logic
  • āœ… Use job queues for background processing
  • āœ… Monitor and alert
  • āœ… Handle failures gracefully

Your users will thank you. Your ops team will thank you. Future you will thank you.

Ready to build bulletproof PDF workflows? Try reportgen.io - async generation built-in.