A question on Stack Overflow puts it about as plainly as it gets. After a successful payment, a Node.js app has to generate a PDF invoice and store it for the user. Do you generate it right inside the payment verification API, hand it to RabbitMQ or BullMQ and a worker, or run it as a background task inside the app?
The usual answer is "always use a queue", and it's argued as if rendering were the heavy part. So we measured rendering first. It isn't the heavy part. The things that should decide this are elsewhere, and they point to an answer smaller than a message broker for most apps.
What one invoice actually costs to render
Same invoice each time: a heading, a paid date, a table of line items. We rendered it 20 times per setup on Node 22.23 in a 4 vCPU container, once with Chromium through Playwright 1.63 (page.setContent then page.pdf) and once with pdfkit 0.17.2, which draws the PDF in JavaScript with no browser at all. We also tracked the worst event-loop delay in the Node process while the renders ran.
| Chromium, warm browser | pdfkit | |
|---|---|---|
| 40 rows, render time (median / max) | 12–13 ms / 24–49 ms | 4–6 ms / 22–25 ms |
| 200 rows, render time (median / max) | 35 ms / 48 ms | 9 ms / 38 ms |
| worst event-loop delay during renders | 2–5 ms | 11–19 ms |
And the costs that only Chromium has:
| Chromium only | |
|---|---|
| first launch in a fresh container | 588 ms |
| relaunch once the binary is in disk cache | 73–96 ms |
| launch, render, close, per request (median of 8) | 148 ms |
| memory of the browser processes, one open page | ~370 MB RSS |
Two things follow from this.
Rendering one invoice is cheap either way. Tens of milliseconds. A payment that took the customer thirty seconds to type in is not waiting on that.
The event loop isn't where Chromium hurts you. The render happens in the browser's own processes, so the Node process barely notices: 2 to 5 ms of loop delay. pdfkit is the reverse. It's faster, but the work runs on your event loop, and at 200 rows it held the loop for up to 19 ms. That's still harmless for one invoice per payment. It's not harmless if a month-end job renders thousands in a loop in the same process that serves your API.
With a browser, what you pay is the launch (if you start one per request), the memory (370 MB sitting next to your API), and the failure mode: a Chromium that hangs or gets OOM-killed takes your request down with it.
So "it's slow, queue it" is the wrong reason. Here are the right ones.
The real reasons to move it out of the request
The payment webhook wants an answer first. If the payment is confirmed by a webhook, the provider tells you how to behave. Stripe's webhook docs say your endpoint "must quickly return a successful status code (2xx) before any complex logic that could cause a timeout." Rendering, uploading the file and sending an email is exactly that kind of logic. If any of it fails and you return a 500, the provider retries, and now you're one bug away from a second invoice.
The same event can arrive twice. Again from Stripe's docs: "Webhook endpoints might occasionally receive the same event more than once." So the real requirement isn't where the PDF is made. It's that a payment produces exactly one invoice, however many times you're told about it. Invoice numbers are usually sequential and legally meaningful, and a duplicate one is a real problem for whoever does the books.
Work started after the response can be lost. A background task inside the app is fine until the process restarts mid-render, on a deploy, a crash or a scale-down. Then nothing remembers it was owed.
None of those three is about CPU. All three are solved by the same small thing.
What I'd do: let a table be the queue
Record that an invoice is owed before you acknowledge, in a row with a unique key on the payment. Acknowledge. Render afterwards. And on startup (and every few minutes) pick up anything still pending.
This runs as written on Node 22 with npm i pdfkit (node:sqlite prints an experimental warning on 22, which is fine for a demo; use your real database):
import http from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import PDFDocument from 'pdfkit';
const db = new DatabaseSync('invoices.db');
db.exec(`CREATE TABLE IF NOT EXISTS invoices (
payment_id TEXT PRIMARY KEY, -- one invoice per payment, enforced here
status TEXT NOT NULL DEFAULT 'pending',
pdf BLOB
)`);
function renderPdf(paymentId) {
return new Promise((resolve) => {
const doc = new PDFDocument({ size: 'A4', margin: 40 });
const chunks = [];
doc.on('data', (c) => chunks.push(c));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.fontSize(18).text(`Invoice for payment ${paymentId}`);
doc.end();
});
}
async function generate(paymentId) {
const pdf = await renderPdf(paymentId);
db.prepare(`UPDATE invoices SET status = 'done', pdf = ? WHERE payment_id = ?`).run(pdf, paymentId);
}
http.createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
// Verify the provider's signature here before trusting anything in body.
const event = JSON.parse(body);
const paymentId = event.data.object.id;
const { changes } = db
.prepare(`INSERT OR IGNORE INTO invoices (payment_id) VALUES (?)`)
.run(paymentId);
res.writeHead(200).end(); // acknowledge first
if (changes === 1) setImmediate(() => generate(paymentId).catch(console.error));
});
}).listen(3000);
// On startup, and every few minutes: anything still 'pending' gets another go.
for (const { payment_id } of db.prepare(`SELECT payment_id FROM invoices WHERE status = 'pending'`).all()) {
generate(payment_id).catch(console.error);
}
Post the same event to it twice and you get two 200s and one row, status = 'done', one PDF. The primary key does the deduplication. The pending status does the recovery. The webhook answers before any rendering starts.
In a real app the INSERT goes in the same transaction that marks the order paid, and the PDF goes to object storage rather than a BLOB column. The shape stays the same.
So which of the three?
Inside the payment verification API, synchronously, only when the user needs the PDF in that very response, and only if it's not a webhook. Then pdfkit or an already-running browser is fast enough. Launching Chromium per request (148 ms and 370 MB each time) isn't a good idea in a request path.
In the background inside the app, with the pending row above. That's the right answer for most apps that sell things one payment at a time. It's also what the Stack Overflow asker's option 3 becomes once it's made restart-safe.
A real queue (BullMQ, RabbitMQ) once something else is true. You run several app instances and need one consumer per job. You need retries with backoff that a deploy can't lose. Or you want Chromium's 370 MB and its crashes out of your API process entirely. The queue earns its place by being durable and isolated. Not by being fast.
Where a rendering API fits, and where it doesn't
We make one, so say it plainly: iReceipt PRO is an API that takes a template and JSON and returns the PDF in the HTTP response. What it takes off your plate is the browser: the launch, the 370 MB, the fonts that render differently in the container, the hung process. What it doesn't take off your plate is anything in the section above. You still acknowledge the webhook first. You still need one invoice per payment, and that's still your unique key. And we don't keep the files: our privacy policy says rendered content isn't stored, so storing the PDF is still yours.
One detail matters for where you call it from. Our JavaScript SDK retries timeouts, 5xx and 429 up to five attempts, sleeping 2, 3, 4 and 5 seconds between them. That's what you want in a worker and the opposite of what you want before a webhook response. So it belongs in generate() above, never before res.writeHead(200).
And where we lose: pdfkit rendered this invoice in 4 to 6 ms on the same machine. No network call can beat that. If your invoice is simple and you're happy drawing it in code, pdfkit is the right tool. If you already keep a warm browser running, 12 ms in-process is hard to argue with too. An API starts making sense when the layout is HTML someone wants to change without a deploy, or when you'd rather not run a browser at all.