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.allSettledor 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
- PDF API Production Checklist — Full production readiness including batch operations
- Automate Invoice PDF Generation — Monthly billing batch example
- PDF API Quickstart — Basic API calls in each language
- Webhook Integration Guide — Set up completion notifications
- PDF API Security Guide — Security for bulk generation
- wkhtmltopdf vs Chromium — Engine choice for performance
- Automated Report PDF Generation — KPI dashboards and monthly reports with batch automation