Choosing a PDF approach for Express
Express gives you routing and middleware, nothing more, so PDF generation is whichever library you plug in. The decision mostly comes down to two questions: do you want to design documents in HTML and CSS, and are you prepared to run a browser inside the same process that serves your API?
| Approach | Design in | Operational cost |
|---|---|---|
| Puppeteer in the route | HTML and CSS | A Chromium process per server that competes with your API for memory; crashes or hangs affect every route |
| A separate Puppeteer worker | HTML and CSS | Isolates the browser, but now you run, scale and monitor a second service |
PDFKit piped into res | JavaScript drawing commands | Lightweight and streams nicely; tables, wrapping and page breaks are your code to write |
| CastPDF | HTML and CSS, or a saved template plus JSON | One outgoing request per document and a plan above the free allowance |
Running Chromium inside an Express process is how many projects begin, and for low traffic it can be enough. Under real load the browser becomes the noisiest tenant on the box: memory climbs, an occasional page renders blank, and a stuck tab ties up a request until something times out. Moving the browser behind an API keeps your Express servers doing what they are good at, which is answering requests quickly.
A statement download route
The example serves monthly account statements for a lending or savings app. It assumes Express 5 and Node 20 or later, with "type": "module" in package.json so the file can use import. Keep the key in CASTPDF_API_KEY (start with a cpdf_test_ key from the dashboard) and the statement template ID in another variable.
import express from 'express';
import { requireUser } from './auth.js';
import { findStatement } from './statements.js';
export class CastPdfError extends Error {
constructor(status, code, message) {
super(message);
this.status = status;
this.code = code;
}
}
async function renderPdf(body, idempotencyKey) {
const res = await fetch('https://api.castpdf.com/v1/pdf', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CASTPDF_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(75_000),
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
const error = payload.error ?? { code: 'unknown', message: res.statusText };
throw new CastPdfError(res.status, error.code, error.message);
}
return Buffer.from(await res.arrayBuffer());
}
const app = express();
app.get('/accounts/:accountId/statements/:month/pdf', requireUser, async (req, res) => {
const statement = await findStatement(req.user.id, req.params.accountId, req.params.month);
if (!statement) {
res.sendStatus(404);
return;
}
const pdf = await renderPdf(
{
template_id: process.env.STATEMENT_TEMPLATE_ID,
data: statement,
filename: `statement-${statement.period.month}`,
},
`statement-${statement.account.number}-${statement.period.month}`,
);
res.attachment(`statement-${statement.period.month}.pdf`);
res.send(pdf);
});
export default app;Notice there is no try block. Express 5 forwards a rejected promise from an async handler to your error middleware automatically, so a CastPDF failure or a timeout lands in one place. On Express 4 that does not happen; wrap the body in try/catch and call next(err), or the rejection goes unhandled. res.attachment() sets Content-Disposition with the filename and picks application/pdf from the .pdf extension, so you do not have to write either header yourself.
Error middleware that speaks CastPDF
Express recognises error middleware by its four arguments. Register it after your routes. This one translates the API’s errors into answers your frontend can act on: a busy or rate limited service becomes a 503 with a Retry-After hint, a timeout becomes 504, and anything else from the API becomes a 502. Your own bugs fall through to the default handler.
import { CastPdfError } from './app.js';
export function pdfErrors(err, req, res, next) {
if (res.headersSent) {
next(err);
return;
}
if (err.name === 'TimeoutError') {
res.status(504).json({ message: 'The document took too long. Please try again.' });
return;
}
if (err instanceof CastPdfError) {
console.error('castpdf', err.status, err.code, err.message);
if (err.status === 429 || err.status === 503) {
res.set('Retry-After', '10').status(503).json({ message: 'Busy right now, try again shortly.' });
return;
}
res.status(502).json({ message: 'The document could not be created.', code: err.code });
return;
}
next(err);
}
// In server.js, after the routes:
// app.use(pdfErrors);
// app.listen(process.env.PORT ?? 3000);The headersSent check covers the rare case where an error happens after the response has started; Express then needs its default handler to close the connection. AbortSignal.timeout() rejects with an error named TimeoutError, which is why the name check works without importing anything. Log err.code rather than the request body, because statements contain personal financial data.
The statement template and its data
The route sends a single JSON object; the layout lives in a saved template you edit in the dashboard with a live preview. A transaction list can easily run to several pages, which is where print mode earns its keep: the column headings repeat on each page, rows stay whole and the footer can say “page 2 of 4”. This is the shape findStatement returns for one month:
{
"template_id": "4a8c2e6f-1b3d-4f5a-9c7e-2d4f6a8b0c1e",
"data": {
"account": { "holder": "Marta Silva", "number": "SAV-00731", "currency": "EUR" },
"period": { "month": "2026-09", "opening_balance": 4120.55, "closing_balance": 4688.1 },
"transactions": [
{ "date": "2026-09-01", "description": "Standing order in", "amount": 500 },
{ "date": "2026-09-14", "description": "Card refund", "amount": 42.9 },
{ "date": "2026-09-30", "description": "Interest", "amount": 24.65 }
]
},
"filename": "statement-2026-09"
}In the template, {{ t.amount | money: account.currency, "pt-PT" }} formats each amount for the reader’s locale, and {{ t.date | format_date: "long" }} writes each date out in full. Send every field the template uses, because data replaces the sample data as a whole.
Redirect to a signed link instead of proxying bytes
For an archive page with many statements, passing every PDF through your server is wasted bandwidth. Add response: 'url' to the body, read url from the JSON reply and answer with res.redirect(303, body.url). The browser downloads straight from CastPDF using a signed link that needs no key and stops working when the stored file expires.
Production checklist for Express
- Load the key from the environment or a secrets manager and keep it out of any bundle served to browsers. Rotate from the test key to a
cpdf_live_key at launch without code changes. - Check every timeout between the user and your route. A render takes up to 30 seconds, and a reverse proxy such as nginx closes upstream reads after 60 seconds by default.
- Put a rate limiter on PDF routes, for example
express-rate-limit. A script that hammers the download URL would otherwise create live documents on your account. - Validate route parameters before calling the API. A
monththat does not matchYYYY-MMshould get a400from you, not a wasted request. - Use an idempotency key built from the account number and month. A browser that retries a slow download then gets the first document, and it is billed once.
- Keep statements below 50 pages and 40 MB. Split very long histories by month rather than exporting a whole year into one file.
Troubleshooting Express PDF routes
| Symptom | Likely cause | Fix |
|---|---|---|
Cannot use import statement outside a module | The project is CommonJS | Add "type": "module" to package.json or rename files to .mjs |
| The process logs an unhandled rejection | Express 4 does not forward errors from async handlers | Upgrade to Express 5, or catch and call next(err) |
ERR_HTTP_HEADERS_SENT | A response was written, then the handler tried to send another | Return after each res.send and check res.headersSent in error middleware |
| The saved file is corrupted | The body was read with res.text() or sent as JSON | Use arrayBuffer() and send a Buffer |
TimeoutError after 75 seconds | A very slow render, often waiting for a remote image | Host images on a fast public URL or embed them as data: URLs |
invalid_request (400) | An unsupported field in the body, such as margin instead of margins | Compare the body with the API reference |
Get a key and a template from the quickstart, and check field names in the API reference. The Node.js page shows the core calls without a framework, and the Next.js page does the same job with Route Handlers and Server Actions.