Document generation looks like a small feature and behaves like an infrastructure problem. Invoices, statements, contracts, exports - each one is fine in isolation, and the load arrives in a spike because everyone runs their month-end on the same two days. A design that renders synchronously in the request handles the demo and falls over exactly when the business needs it most.
Generate asynchronously, always. The request creates a job and returns immediately; a worker renders the document, stores it, and notifies the user by email or a status the interface polls. This changes the user experience from a spinner that sometimes times out into a progress indicator, and more importantly it means a hundred simultaneous report requests queue rather than exhausting your web servers.
For the rendering itself, the two viable approaches trade off differently. A headless browser rendering HTML gives you the full power of CSS, is easy for anyone on the team to modify, and is heavy - each instance consumes hundreds of megabytes and a browser process. A dedicated PDF library is far lighter and faster and gives you a much more constrained layout model. If the document is complex and its appearance matters commercially, HTML rendering usually wins despite the cost; if you are producing millions of simple documents, the library does.
Isolate the rendering workers from everything else. A headless browser that leaks memory - and they do - should degrade a pool of report workers rather than your API. Separate deployment, separate scaling policy, generous memory limits, and a hard timeout per document so one pathological input cannot occupy a worker indefinitely.
The failure that catches teams out is data volume rather than rendering. An export of ten million rows built by loading everything into memory will exhaust the worker regardless of how efficient the renderer is. Stream from a database cursor and write incrementally, so memory stays constant no matter the size. For very large exports, produce a file in object storage and hand the user a signed download link rather than trying to deliver it through your application.
Two things worth building in from the start. Deduplicate identical requests - if the same invoice is requested three times, generate once and serve the stored copy, because users click twice and integrations retry. And version your templates, storing which version produced each document. When a regulator or customer asks why last March's statement looks different from this one, the answer needs to exist, and reconstructing it from git history is considerably less pleasant than reading a field.