Generating PDF Reports and Invoices in a SaaS Without Breaking Production
Founder & Lead Engineer, RAITHub
RAITHub ships and tests production software. See QA as a Service or talk to us.
Generate PDFs in a SaaS by rendering an HTML template to PDF for anything that looks like a document (invoices, reports, statements), running the render as a background job rather than inside the web request, storing the result in object storage, and giving each invoice a gap-free number assigned in a transaction. Drawing libraries suit fixed layouts; a headless browser suits rich ones.
This is engineering guidance for SaaS on Node and PostgreSQL. If you would rather have it built and tested for you, see how RAITHub would build this below.
Which approach should you use: HTML-to-PDF or a PDF library?
For invoices, statements and reports that reuse your existing styles, render HTML and convert it to PDF with a headless browser. For a fixed, print-exact layout (a shipping label, a certificate) or very high volume, a PDF-drawing library gives tighter control and a smaller footprint. Most SaaS documents fall in the first group.
| Approach | Good at | Weak at | Choose when |
|---|---|---|---|
| Headless browser (HTML → PDF) | Reusing web styles, complex layout, charts | Heavy memory and cold starts; needs a real browser binary | Invoices and reports that match your web UI |
| PDF-drawing library | Deterministic output, low memory, high volume | Layout is code, not CSS; slow to iterate | Fixed templates, labels, very high volume |
| A hosted PDF/document API | No browser to run or patch | Per-document cost; data leaves your cloud | Low volume, or you do not want to run a browser |
A headless browser is a real browser: Chrome's own team warns in the Puppeteer Docker guide that it needs system libraries and specific flags to run in a container. That is why you isolate it, below, rather than embedding it in your web process.
Why should PDF generation run off the web request?
Because rendering a document can take seconds and a lot of memory, and a web request should return in milliseconds. Launching a browser inside the request that serves your UI competes for memory with every other request and can exceed a serverless function's limits. Run generation as a background job: the user clicks "download", you enqueue the work, and the file appears when it is ready, or the request waits on a job that runs in a separate worker.
// Web request: enqueue, return immediately.
export async function requestInvoicePdf(invoiceId: string, tenantId: string) {
const job = await db.insert('pdf_jobs', {
tenant_id: tenantId,
kind: 'invoice',
target_id: invoiceId,
status: 'queued',
})
await queue.add('render-pdf', { jobId: job.id })
return { jobId: job.id } // client polls, or you push when done
}
// Worker: one browser for the worker, a fresh page per job.
import puppeteer from 'puppeteer'
const browser = await puppeteer.launch({ args: ['--no-sandbox'] })
export async function renderPdf(html: string): Promise<Buffer> {
const page = await browser.newPage()
try {
await page.setContent(html, { waitUntil: 'networkidle0' })
return await page.pdf({ format: 'A4', printBackground: true })
} finally {
await page.close() // always close the page, or memory leaks per job
}
}
Launch the browser once per worker and open a fresh page per job, closing it in a finally block. A browser left open per request, or a page never closed, is the usual cause of a worker that slowly runs out of memory. Background jobs and a PostgreSQL-backed queue are covered more fully in the scheduler and recurring jobs guide.
How do you make invoice numbers correct and gap-free?
Invoice numbers are a legal and accounting artefact, not a cosmetic string. Many jurisdictions expect a sequential series with no gaps, per tenant. Do not derive the number from a row ID or a timestamp, and do not generate it in application code where two requests can read the same last value. Assign it inside a transaction that locks the tenant's counter.
-- One counter row per tenant; the UPDATE ... RETURNING is atomic.
CREATE TABLE invoice_counters (
tenant_id uuid PRIMARY KEY REFERENCES tenants(id),
next_seq bigint NOT NULL DEFAULT 1
);
-- Inside the transaction that finalises the invoice:
WITH n AS (
UPDATE invoice_counters
SET next_seq = next_seq + 1
WHERE tenant_id = $1
RETURNING next_seq - 1 AS seq
)
UPDATE invoices
SET number = 'INV-' || to_char((SELECT seq FROM n), 'FM000000'),
status = 'issued',
issued_at = now()
WHERE id = $2;
Because the counter update and the invoice update share one transaction, a rollback gives the number back and two concurrent requests cannot claim the same one. Assign the number only when the invoice is issued, not when it is drafted, so cancelled drafts do not burn numbers. Treat an issued invoice as immutable: corrections are a credit note, a new document, which keeps the series honest. The money amounts on it come from a ledger in minor units, as in the multi-currency support guide.
Should you store generated PDFs or regenerate them?
Store them. An issued invoice must look the same in two years even if your template changes, so render it once, write it to object storage, and serve a short-lived signed URL on download. The safe upload, storage and signed-link pattern is the same one in the file storage and sharing guide.
Reports that reflect live data are different: cache them for a short window keyed by the tenant, the report type and the date range, and regenerate when the window passes or the underlying data changes. A customer who asks for "last month" twice in a minute should get the cached file the second time.
Do-it-yourself estimate: 1–2 weeks for a templated invoice or report with a worker, storage and gap-free numbering, if the data and tenancy are already in place. The main risks are duplicated invoice numbers and a browser that leaks memory under load.
Buy, build or hire?
| Option | Examples | Choose this when | Watch out for |
|---|---|---|---|
| A hosted PDF/document API | HTML-to-PDF and document-generation APIs | Low volume and you do not want to run a browser | Per-document cost, and your data leaves your cloud |
| A PDF library in your app | Server-side PDF drawing libraries | Fixed layouts, high volume, deterministic output | Layout is code; iterating on design is slow |
| Headless browser you run | Puppeteer or Playwright in a worker | Documents must match your web styling and charts | Browser memory, cold starts and patching the binary |
| Hire a team to build it | RAITHub or another studio | Invoices are financial records that must be correct and auditable | Get the numbering tests and the worker setup in the handover |
How do you test PDF generation?
- Numbering: issue many invoices concurrently and assert the numbers are sequential, unique and gap-free per tenant.
- Immutability: change the template, re-open an old invoice, and assert the stored PDF is unchanged.
- Isolation: request tenant A's invoice as tenant B and expect a refusal.
- Content: extract text from the generated PDF and assert the total, tax and line items match the record, rather than comparing pixels.
- Resource safety: run many jobs in a row and assert worker memory returns to baseline (no leaked pages).
How RAITHub would build this
- Templating: HTML templates that reuse your styles, rendered to PDF in a dedicated worker, not the web request.
- Financial correctness: gap-free, per-tenant invoice numbers assigned in a transaction, issued invoices treated as immutable, corrections as credit notes.
- Storage: generated documents written to object storage with signed-URL downloads; live reports cached by tenant and date range.
- Tests: concurrency, numbering, isolation and content-extraction tests in CI.
Timeline: PDF reporting inside a new SaaS build fits the 4–6 week fixed scope; added to an existing back end it is a bounded piece in the 6–12 week backend range. You receive: numbering and isolation tests in CI, handover docs and runbooks, and full IP under NDA.
Next step: a free 15-minute technical audit, then a written fixed quote. See the SaaS development service, compare how billing feeds documents in the SaaS billing models guide, and book the audit.
Frequently asked questions
Should I use HTML-to-PDF or a PDF library for invoices?
HTML-to-PDF for documents that reuse your web styles and layout, which most invoices and reports do. A PDF-drawing library suits fixed, print-exact layouts and very high volume, where deterministic output and low memory matter more than design speed.
Why shouldn't I generate PDFs inside the web request?
Rendering can take seconds and a lot of memory, while a web request should return in milliseconds. A browser launched inside the request competes with every other request and can exceed serverless limits. Run generation in a background worker.
How do I stop duplicate invoice numbers?
Assign the number inside the transaction that issues the invoice, from a per-tenant counter updated atomically. Never derive it from a row ID, timestamp or application-side read, where two concurrent requests can claim the same value.
Should generated invoices be stored or regenerated each time?
Stored. An issued invoice must look identical years later even after template changes, so render once and keep the file. Reports on live data can be cached briefly and regenerated when the data or the cache window changes.
Can I correct an invoice after it is issued?
Treat an issued invoice as immutable and correct it with a credit note or a new invoice, which keeps the number series gap-free and auditable. This is general accounting practice; confirm your local invoicing rules with your adviser.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.