Invalid Date

End-of-month invoices, post-training certificates, quarterly reports — businesses regularly need to generate hundreds or thousands of PDFs at once. Calling the API one at a time is slow and fragile.

This guide covers how to efficiently batch generate PDFs using the batch API, concurrency patterns, and proper error handling.

The Problem with Sequential Generation

// BAD: Sequential generation — 100 items takes minutes
for (const customer of customers) {
  await generatePdf(customer);  // 1-3 seconds each
}
// 100 × 2s = 200 seconds (3+ minutes)

Sequential processing is slow, and recovering from mid-batch errors is painful.

Why Batch Processing

  • Fast: Process multiple items concurrently (100 items in seconds)
  • Efficient: Fewer network round trips
  • Robust: Individual error handling with partial success
  • Scalable: Leverage the API's auto-scaling

Using the Batch API

FUNBREW PDF's batch endpoint generates multiple PDFs in a single request.

Basic Usage

const response = await fetch('https://api.pdf.funbrew.cloud/v1/pdf/batch', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.FUNBREW_PDF_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    items: customers.map(c => ({
      type: 'html',
      html: buildInvoiceHtml(c),
      filename: `invoice-${c.id}.pdf`,
      engine: 'quality',
      format: 'A4',
    })),
  }),
});

const { data } = await response.json();
console.log(`Generated ${data.results.length} PDFs`);

data.results.forEach(result => {
  if (result.status === 'success') {
    console.log(`✓ ${result.filename}: ${result.download_url}`);
  } else {
    console.error(`✗ ${result.filename}: ${result.error}`);
  }
});

Batch with Templates

Combine with the template engine to skip HTML assembly entirely.

const items = customers.map(c => ({
  type: 'template',
  template: 'invoice',
  variables: {
    customer_name: c.name,
    invoice_number: `INV-${c.id}`,
    line_items: buildLineItemsHtml(c.items),
    total: c.total.toLocaleString(),
    due_date: c.dueDate,
  },
  filename: `invoice-${c.id}.pdf`,
  email: {
    to: c.email,
    subject: `Invoice ${c.name} - March 2026`,
  },
}));

PDF generation and email delivery in one request. See the invoice automation guide for the full workflow.

Concurrency Patterns

For custom batch implementations with controlled parallelism.

Node.js: Promise.allSettled with Chunking

async function generateBatch(items, concurrency = 10) {
  const results = [];

  for (let i = 0; i < items.length; i += concurrency) {
    const chunk = items.slice(i, i + concurrency);
    const chunkResults = await Promise.allSettled(
      chunk.map(item => generateSinglePdf(item))
    );
    results.push(...chunkResults);
  }

  const succeeded = results.filter(r => r.status === 'fulfilled').length;
  const failed = results.filter(r => r.status === 'rejected').length;
  console.log(`Done: ${succeeded} succeeded, ${failed} failed`);

  return results;
}

async function generateSinglePdf(item) {
  const response = await fetch('https://api.pdf.funbrew.cloud/v1/pdf/from-html', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.FUNBREW_PDF_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html: item.html,
      engine: 'quality',
      format: 'A4',
    }),
  });

  if (!response.ok) throw new Error(`${item.id}: HTTP ${response.status}`);
  return { id: item.id, pdf: await response.arrayBuffer() };
}

Python: asyncio + Semaphore

import asyncio
import httpx
import os

async def generate_batch(items, concurrency=10):
    semaphore = asyncio.Semaphore(concurrency)
    async with httpx.AsyncClient() as client:
        tasks = [generate_one(client, semaphore, item) for item in items]
        results = await asyncio.gather(*tasks, return_exceptions=True)

    succeeded = sum(1 for r in results if not isinstance(r, Exception))
    failed = sum(1 for r in results if isinstance(r, Exception))
    print(f"Done: {succeeded} succeeded, {failed} failed")
    return results

async def generate_one(client, semaphore, item):
    async with semaphore:
        response = await client.post(
            'https://api.pdf.funbrew.cloud/v1/pdf/from-html',
            headers={'Authorization': f'Bearer {os.environ["FUNBREW_PDF_API_KEY"]}'},
            json={'html': item['html'], 'engine': 'quality', 'format': 'A4'},
            timeout=30,
        )
        response.raise_for_status()
        return {'id': item['id'], 'pdf': response.content}

PHP (Laravel): Job Queue

For large batches, use Laravel's queue system for automatic retries and rate limiting.

class GeneratePdfJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        private Customer $customer,
        private string $month,
    ) {}

    public function handle(): void
    {
        $response = Http::withToken(config('services.funbrew.api_key'))
            ->post('https://api.pdf.funbrew.cloud/v1/pdf/from-template', [
                'template' => 'invoice',
                'variables' => [
                    'customer_name' => $this->customer->name,
                    'total' => number_format($this->customer->total),
                ],
                'email' => [
                    'to' => $this->customer->email,
                    'subject' => "Invoice for {$this->customer->name} - {$this->month}",
                ],
            ]);

        if ($response->failed()) {
            throw new \Exception("PDF generation failed: {$response->status()}");
        }
    }

    public int $tries = 3;
    public int $backoff = 60;
}

// Dispatch jobs
Customer::chunk(100, function ($customers) {
    foreach ($customers as $customer) {
        GeneratePdfJob::dispatch($customer, 'March 2026');
    }
});

Queue systems give you automatic retries, delayed execution, and priority control.

Error Handling

Batch processing means partial failures are expected. Handle them gracefully.

Retry Strategy

async function generateWithRetry(item, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await generateSinglePdf(item);
    } catch (error) {
      if (attempt === maxRetries) throw error;
      // Exponential backoff: 1s, 2s, 4s...
      await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
    }
  }
}

Failure Rate Monitoring

Stop processing if too many items fail — something is likely wrong with the input or the service.

let failCount = 0;
const failThreshold = items.length * 0.1; // Stop at 10% failure

for (const chunk of chunks) {
  const results = await Promise.allSettled(chunk.map(generateSinglePdf));
  failCount += results.filter(r => r.status === 'rejected').length;

  if (failCount > failThreshold) {
    console.error(`Failure rate exceeded threshold (${failCount}). Stopping.`);
    break;
  }
}

Completion Notifications with Webhooks

Use webhooks to get notified when a batch completes — no polling needed.

{
  "event": "batch.completed",
  "data": {
    "batch_id": "batch_abc123",
    "total": 100,
    "succeeded": 98,
    "failed": 2
  }
}

Performance Tips

Technique Impact Effort
Use fast engine 2-3x faster generation Low
Use templates Reduce payload size Low
Optimize concurrency Better throughput Medium
Switch images from Base64 to URLs Smaller payloads Medium
Tune batch size Better memory efficiency Medium

For engine characteristics, see wkhtmltopdf vs Chromium. The fast engine is often sufficient when design fidelity isn't critical.

Benchmark: Sequential vs Batch vs Concurrent

These are approximate figures; actual results depend on content complexity and network conditions.

Method 100 PDFs 500 PDFs Suitable scale
Sequential (one by one) ~200s (3 min) ~1,000s (17 min) <10/day
Batch API ~20–40s ~100–200s <10,000/day
Concurrent (concurrency=10) ~25–50s ~125–250s <100,000/day
Batch + concurrent (optimised) ~10–20s ~50–100s <1,000,000/day

Using the batch API alone gives you 5–10× the throughput of sequential calls.

Troubleshooting

Timeouts

Scale your timeout to the number of items.

function getTimeout(itemCount) {
  return itemCount * 3000 + 30000; // 3s per item + 30s buffer
}

const response = await fetch('https://pdf.funbrew.cloud/api/v1/batch', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${API_KEY}` },
  body: JSON.stringify({ items }),
  signal: AbortSignal.timeout(getTimeout(items.length)),
});

Partial failures

The batch API supports partial success. Extract failed items and retry them separately.

result = await generate_batch(items)
failed_items = [
    items[i]
    for i, r in enumerate(result['data']['results'])
    if r['status'] == 'failed'
]
if failed_items:
    retry_result = await generate_batch(failed_items)

Payload too large

Use templates instead of sending full HTML every time — the payload shrinks from several KB per item to a few hundred bytes.

// Before: full HTML in every item
items.map(c => ({ type: 'html', html: buildFullHtml(c) }))

// After: template ID + variables only
items.map(c => ({
  type: 'template',
  template: 'invoice',
  variables: { name: c.name, total: c.total },
}))

Summary

Efficient bulk PDF generation boils down to:

  • Batch API: Multiple PDFs plus email delivery in one request
  • Concurrency: Promise.allSettled or asyncio with controlled parallelism
  • Queues: Laravel jobs for automatic retries and scheduling
  • Error handling: Retry strategies and failure rate monitoring
  • Webhooks: Completion notifications without polling

Start with the free plan (30/month) to test the batch API. Try it in the Playground and check the API documentation for full specs.

Related

Powered by FUNBREW PDF