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.