reportgen
Back to all posts

Scaling PDF Generation at Enterprise Level: From 1K to 1M PDFs Per Month 📈

June 6, 2025

Month 1: 1,000 PDFs. Everything works great.

Month 6: 10,000 PDFs. Starting to slow down, but manageable.

Month 12: 100,000 PDFs. Your servers are melting. Your database is choking. Your monitoring is screaming.

Month 18: You need to hit 1,000,000 PDFs/month. How do you get there without everything collapsing?

This guide shows you exactly how to scale PDF generation from thousands to millions - with real architecture patterns, code examples, and lessons learned the hard way.


The Scaling Journey: What Breaks at Each Stage

Stage 1: 1K-10K PDFs/month

What works: Single server, synchronous generation, basic caching

What breaks: Nothing yet. You're fine.

Cost: ~$25/month


Stage 2: 10K-100K PDFs/month

What works: Async generation, Redis caching, basic queue

What starts breaking:

  • Synchronous generation times out
  • Memory leaks become visible
  • Database connections saturate
  • Single server can't keep up

Cost: ~$200/month

Action needed: Move to async workflows, implement caching, add monitoring


Stage 3: 100K-500K PDFs/month

What breaks hard:

  • Queue backlogs during peak hours
  • Storage costs skyrocket
  • Database queries slow down
  • Memory issues cause crashes
  • No visibility into failures

Cost: ~$800/month (if unoptimized)

Action needed: Horizontal scaling, database optimization, aggressive caching, cost optimization


Stage 4: 500K-1M+ PDFs/month

Enterprise scale. Everything matters:

  • Every millisecond of generation time
  • Every MB of storage
  • Every database query
  • Every cache miss

Cost: $2,000-5,000/month (if optimized), $15,000+ (if not)

Action needed: Distributed architecture, sharding, advanced caching, predictive scaling


Architecture Pattern 1: Async Queue-Based System (10K-100K PDFs)

The Problem: Blocking API Requests

// ❌ This breaks at scale
app.post('/generate-invoice', async (req, res) => {
  const pdf = await generatePDF(req.body); // Blocks for 2-5 seconds
  res.json({ pdf_url: pdf.url });
  // Can't handle > 10 concurrent requests
});

The Solution: Job Queue

import Bull from 'bull';
 
const pdfQueue = new Bull('pdf-generation', {
  redis: process.env.REDIS_URL,
  settings: {
    maxStalledCount: 3,
    stalledInterval: 30000
  }
});
 
// Producer: Accept requests instantly
app.post('/generate-invoice', async (req, res) => {
  const job = await pdfQueue.add('generate-pdf', {
    template: 'invoice',
    data: req.body,
    user_id: req.user.id
  }, {
    attempts: 3,
    backoff: {
      type: 'exponential',
      delay: 2000
    }
  });
 
  res.json({
    job_id: job.id,
    status: 'queued',
    status_url: `/pdf-status/${job.id}`
  });
  // Returns in < 50ms
});
 
// Consumer: Process jobs in background
pdfQueue.process('generate-pdf', 10, async (job) => {
  const { template, data, user_id } = job.data;
 
  // Update progress
  job.progress(10);
 
  const pdf = await generatePDF(template, data);
 
  job.progress(80);
 
  const url = await uploadToS3(pdf);
 
  job.progress(100);
 
  return { pdf_url: url };
});
 
// Handle completion
pdfQueue.on('completed', async (job, result) => {
  await db.pdfs.update(
    { job_id: job.id },
    { status: 'completed', pdf_url: result.pdf_url }
  );
 
  await notifyUser(job.data.user_id, result.pdf_url);
});
 
// Handle failures
pdfQueue.on('failed', async (job, err) => {
  await db.pdfs.update(
    { job_id: job.id },
    { status: 'failed', error: err.message }
  );
 
  await alerting.notify({
    severity: 'error',
    message: `PDF generation failed for job ${job.id}`,
    error: err.message
  });
});

Capacity: Can handle 100K+ PDFs/month with 5-10 workers


Architecture Pattern 2: Distributed Workers (100K-500K PDFs)

Horizontal Scaling with Multiple Workers

# docker-compose.yml
version: '3.8'
 
services:
  api:
    image: your-app:latest
    deploy:
      replicas: 3
    environment:
      - REDIS_URL=redis://redis:6379
      - DATABASE_URL=postgresql://db:5432/app
 
  pdf-worker:
    image: your-pdf-worker:latest
    deploy:
      replicas: 10  # Scale workers independently
    environment:
      - REDIS_URL=redis://redis:6379
      - CONCURRENCY=5  # Each worker processes 5 jobs concurrently
 
  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data
 
  postgres:
    image: postgres:15
    volumes:
      - postgres-data:/var/lib/postgresql/data

Worker Configuration

// worker.js
const CONCURRENCY = parseInt(process.env.CONCURRENCY || '5');
 
pdfQueue.process('generate-pdf', CONCURRENCY, async (job) => {
  // Process job
});
 
// Graceful shutdown
process.on('SIGTERM', async () => {
  console.log('SIGTERM received, shutting down gracefully...');
 
  await pdfQueue.close();
  process.exit(0);
});

Capacity: Can handle 500K+ PDFs/month with 10 workers @ 5 concurrency each


Architecture Pattern 3: Multi-Tier Queues (500K-1M+ PDFs)

Priority-Based Queue System

const queues = {
  high: new Bull('pdf-high-priority', { redis: redisConfig }),
  normal: new Bull('pdf-normal-priority', { redis: redisConfig }),
  low: new Bull('pdf-low-priority', { redis: redisConfig })
};
 
function getPriority(jobType, userTier) {
  // Paying customers get priority
  if (userTier === 'enterprise') return 'high';
 
  // Real-time invoices are high priority
  if (jobType === 'invoice') return 'high';
 
  // Bulk reports are low priority
  if (jobType === 'bulk-report') return 'low';
 
  return 'normal';
}
 
app.post('/generate-pdf', async (req, res) => {
  const priority = getPriority(req.body.type, req.user.tier);
 
  const job = await queues[priority].add({
    template: req.body.template,
    data: req.body.data
  });
 
  res.json({ job_id: job.id, priority });
});
 
// Allocate workers by priority
queues.high.process(20, processJob);   // 20 workers for high priority
queues.normal.process(10, processJob); // 10 workers for normal
queues.low.process(5, processJob);     // 5 workers for low (use spare capacity)

Database Optimization for Scale

Problem: Database Becomes Bottleneck

// ❌ N+1 query problem
for (const job of jobs) {
  const user = await db.users.findOne({ id: job.user_id });
  const template = await db.templates.findOne({ id: job.template_id });
  // 1,000 jobs = 2,000 queries
}

Solution: Batch Queries and Connection Pooling

// ✅ Batch load
const userIds = jobs.map(j => j.user_id);
const templateIds = jobs.map(j => j.template_id);
 
const [users, templates] = await Promise.all([
  db.users.find({ id: { $in: userIds } }),
  db.templates.find({ id: { $in: templateIds } })
]);
 
const userMap = new Map(users.map(u => [u.id, u]));
const templateMap = new Map(templates.map(t => [t.id, t]));
 
// 1,000 jobs = 2 queries

Connection Pooling

// Database connection pool
const pool = new Pool({
  host: process.env.DB_HOST,
  database: process.env.DB_NAME,
  max: 20,              // Max 20 connections
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});
 
// Use pool instead of creating new connections
const result = await pool.query('SELECT * FROM pdfs WHERE id = $1', [id]);

Read Replicas

// Write to primary
const writePrimary = new Pool({ host: 'primary.db.example.com' });
 
// Read from replicas (load balanced)
const readReplicas = [
  new Pool({ host: 'replica1.db.example.com' }),
  new Pool({ host: 'replica2.db.example.com' }),
  new Pool({ host: 'replica3.db.example.com' })
];
 
function getReadPool() {
  return readReplicas[Math.floor(Math.random() * readReplicas.length)];
}
 
// Write operations
await writePrimary.query('INSERT INTO pdfs ...');
 
// Read operations (distributed across replicas)
await getReadPool().query('SELECT * FROM pdfs WHERE user_id = $1', [userId]);

Caching Strategy for Scale

Multi-Layer Cache

import NodeCache from 'node-cache';
import Redis from 'ioredis';
 
// Layer 1: In-memory (fastest, limited size)
const memoryCache = new NodeCache({
  stdTTL: 300,      // 5 minutes
  maxKeys: 1000     // Keep only 1,000 PDFs in memory
});
 
// Layer 2: Redis (fast, distributed)
const redis = new Redis(process.env.REDIS_URL);
 
// Layer 3: S3 (slow, unlimited)
// PDFs always stored here
 
async function getCachedPDF(key) {
  // Try memory cache
  let pdf = memoryCache.get(key);
  if (pdf) {
    metrics.counter('cache.memory.hit');
    return pdf;
  }
 
  // Try Redis cache
  pdf = await redis.getBuffer(key);
  if (pdf) {
    metrics.counter('cache.redis.hit');
    memoryCache.set(key, pdf); // Promote to memory
    return pdf;
  }
 
  // Try S3
  metrics.counter('cache.miss');
  pdf = await downloadFromS3(key);
 
  if (pdf) {
    // Cache in both layers
    redis.setex(key, 3600, pdf);  // 1 hour in Redis
    memoryCache.set(key, pdf);     // 5 minutes in memory
  }
 
  return pdf;
}

Cache Warming

// Pre-warm cache for predictable traffic
async function warmCache() {
  // Top 100 most-requested PDFs
  const popularPDFs = await db.pdfs.find()
    .sort({ request_count: -1 })
    .limit(100);
 
  for (const pdf of popularPDFs) {
    const pdfBuffer = await downloadFromS3(pdf.s3_key);
    await redis.setex(pdf.id, 3600, pdfBuffer);
  }
 
  console.log(`Warmed cache with ${popularPDFs.length} PDFs`);
}
 
// Run on startup and periodically
warmCache();
setInterval(warmCache, 60 * 60 * 1000); // Every hour

Auto-Scaling Based on Queue Depth

Kubernetes Horizontal Pod Autoscaler

# k8s/pdf-worker-hpa.yml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: pdf-worker
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: pdf-worker
  minReplicas: 5
  maxReplicas: 50
  metrics:
  - type: External
    external:
      metric:
        name: redis_queue_depth
        selector:
          matchLabels:
            queue: pdf-generation
      target:
        type: AverageValue
        averageValue: "100"  # Scale when queue > 100 jobs per pod
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60
      policies:
      - type: Percent
        value: 100  # Double pods if needed
        periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
      - type: Percent
        value: 50   # Scale down slowly
        periodSeconds: 60

Queue Depth Monitoring

// Expose queue metrics for autoscaler
app.get('/metrics', async (req, res) => {
  const waiting = await pdfQueue.getWaitingCount();
  const active = await pdfQueue.getActiveCount();
  const failed = await pdfQueue.getFailedCount();
 
  res.set('Content-Type', 'text/plain');
  res.send(`
# HELP pdf_queue_waiting Number of jobs waiting in queue
# TYPE pdf_queue_waiting gauge
pdf_queue_waiting ${waiting}
 
# HELP pdf_queue_active Number of jobs actively processing
# TYPE pdf_queue_active gauge
pdf_queue_active ${active}
 
# HELP pdf_queue_failed Number of failed jobs
# TYPE pdf_queue_failed gauge
pdf_queue_failed ${failed}
  `);
});

Rate Limiting and Backpressure

Prevent Queue Overflow

import rateLimit from 'express-rate-limit';
 
// Per-user rate limit
const userLimiter = rateLimit({
  windowMs: 60 * 1000,  // 1 minute
  max: async (req) => {
    // Different limits by tier
    const limits = {
      free: 10,
      pro: 100,
      enterprise: 1000
    };
    return limits[req.user.tier] || limits.free;
  },
  keyGenerator: (req) => req.user.id,
  message: 'Too many PDF generation requests. Please try again later.'
});
 
app.post('/generate-pdf', userLimiter, async (req, res) => {
  // Only accept if queue isn't overwhelmed
  const queueDepth = await pdfQueue.getWaitingCount();
 
  if (queueDepth > 10000) {
    return res.status(503).json({
      error: 'Service temporarily unavailable. Queue is full.',
      retry_after: 60
    });
  }
 
  // Accept job
  const job = await pdfQueue.add(req.body);
  res.json({ job_id: job.id });
});

Monitoring and Observability at Scale

Key Metrics to Track

import { metrics } from './monitoring';
 
async function processJob(job) {
  const startTime = Date.now();
 
  try {
    const pdf = await generatePDF(job.data);
 
    // Track success metrics
    metrics.histogram('pdf.generation.duration', Date.now() - startTime, {
      template: job.data.template,
      user_tier: job.data.user_tier
    });
 
    metrics.counter('pdf.generation.success', 1, {
      template: job.data.template
    });
 
    metrics.histogram('pdf.file.size', pdf.length, {
      template: job.data.template
    });
 
    return pdf;
 
  } catch (err) {
    // Track failure metrics
    metrics.counter('pdf.generation.failure', 1, {
      template: job.data.template,
      error_type: err.name
    });
 
    throw err;
  }
}
 
// Alert on anomalies
setInterval(async () => {
  const queueDepth = await pdfQueue.getWaitingCount();
  const oldestJob = await pdfQueue.getJob(await pdfQueue.getNextJobId());
 
  if (queueDepth > 5000) {
    await alerting.notify({
      severity: 'warning',
      message: `Queue depth is ${queueDepth}`
    });
  }
 
  if (oldestJob && Date.now() - oldestJob.timestamp > 300000) {
    await alerting.notify({
      severity: 'critical',
      message: 'Jobs waiting over 5 minutes in queue'
    });
  }
}, 60000); // Check every minute

Cost Optimization at Scale

Storage Lifecycle Management

// Automatically delete old PDFs
const s3LifecyclePolicy = {
  Rules: [
    {
      Id: 'DeleteOldInvoices',
      Status: 'Enabled',
      Expiration: { Days: 90 },
      Filter: { Prefix: 'invoices/' }
    },
    {
      Id: 'ArchiveOldReports',
      Status: 'Enabled',
      Transitions: [
        {
          Days: 30,
          StorageClass: 'GLACIER_IR'  // Cheaper storage after 30 days
        }
      ],
      Filter: { Prefix: 'reports/' }
    }
  ]
};

Spot Instances for Workers

# k8s/pdf-worker-deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pdf-worker
spec:
  replicas: 10
  template:
    spec:
      nodeSelector:
        node-type: spot  # Use spot instances (70% cheaper)
      tolerations:
      - key: spot
        operator: Equal
        value: "true"
        effect: NoSchedule

Real-World Scaling Case Study

Company: SaaS platform generating financial reports

Journey:

  • Month 1: 5K PDFs, single server
  • Month 6: 50K PDFs, async queue added
  • Month 12: 250K PDFs, distributed workers
  • Month 18: 1M PDFs, full auto-scaling architecture

Architecture Evolution:

Stage 1 (5K/month):
- Single Node.js server
- Synchronous generation
- Local file storage
Cost: $25/month

Stage 2 (50K/month):
- 3 API servers
- Bull queue + 5 workers
- Redis + S3
Cost: $180/month

Stage 3 (250K/month):
- 5 API servers
- 15 workers (auto-scaling 10-30)
- Redis cluster
- S3 + CloudFront
- Read replicas
Cost: $650/month

Stage 4 (1M/month):
- Kubernetes cluster
- 20-50 workers (auto-scaling)
- Multi-tier caching
- Database sharding
- Spot instances
Cost: $1,800/month (optimized from $8,000)

Scaling Checklist

10K-100K PDFs/month:

  • Async job queue (Bull/BullMQ)
  • Redis caching
  • Background workers
  • Basic monitoring

100K-500K PDFs/month:

  • Horizontal worker scaling
  • Database connection pooling
  • Multi-layer caching
  • Auto-scaling based on queue depth
  • Rate limiting
  • Storage lifecycle policies

500K-1M+ PDFs/month:

  • Kubernetes orchestration
  • Priority-based queues
  • Read replicas
  • Cache warming
  • Spot instances for workers
  • Comprehensive monitoring/alerting
  • Cost optimization automation

Final Thoughts

Scaling PDF generation is about architecture, not magic.

Start simple. Add complexity only when needed. Monitor everything. Optimize costs ruthlessly.

The path from 1K to 1M PDFs is well-traveled. Follow proven patterns, and you'll get there without disaster.

Ready to scale without the headaches? Try reportgen.io - built to handle millions of PDFs with zero infrastructure management.