The blueprint: an HTML-to-PDF API on Postgres, Chromium, and not much else — Adrian Ani
← Back to articles architecturecase-studybuild-vs-buy<br>The blueprint: an HTML-to-PDF API on Postgres, Chromium, and not much else<br>Jul 7, 2026<br>PDF generation is a solved problem in 2026. Puppeteer drives headless Chrome. LiquidJS renders templates with real logic. S3 stores blobs. What’s not solved by gluing those together is the production shape around them:<br>A document can be 50 KB or hundreds of megabytes, and your memory usage can’t be allowed to care.<br>Traffic arrives in bursts (someone enqueues 10,000 documents at once), but rendering is CPU-bound and slow.<br>Documents carry names, amounts, and addresses; sensitive by default.<br>And if you’re a small team or a solo operator, whatever you build has to be runnable by the people you actually have.<br>I built DocPenny around those four constraints: solo, in about twelve weeks. This article is the blueprint: the architecture decisions in enough detail to guide someone building the same thing. At the end, a framework for deciding whether you should.<br>The bill of materials<br>SvelteKit. Hono. pg-boss. PostgreSQL. Chromium. S3-compatible storage. That’s the complete list.<br>No Redis. No Kafka. No Kubernetes. No connection pooler. I ran one for a while and deleted it; at this scale, direct Postgres connections from the API and workers are one less thing to configure, monitor, and debug.<br>The load-bearing choice is pg-boss : a job queue that runs on the Postgres you already have. One database for application data and async jobs. One thing to back up. One thing to monitor. The conventional alternative, Redis + BullMQ, adds a second stateful service that can fail independently, and its failure modes (persistence config, eviction policies, split-brain on restart) are exactly the kind you debug at 3 AM.<br>Which matters, because here’s the operational reality of a solo product: there is no on-call rotation. There’s me. If it breaks overnight, it waits until I wake up, find the issue, fix it, test it, and deploy. Every dependency you don’t have is one that can’t be the reason it’s down. The lean stack isn’t an aesthetic; it’s what makes solo operation survivable.<br>One more piece: Hono’s RPC client gives end-to-end type safety between frontend and API with no code-generation step. Change a route, the compiler flags every callsite. When there’s no QA team behind you, the compiler is the QA team.<br>The architecture in one diagram<br>Client → API (Hono) → pg-boss queue (Postgres)<br>Workers (N replicas, CDP pool)<br>Chromium renders → stream → S3 multipart<br>Webhook (HMAC-signed) → client The critical property: ingestion and rendering are decoupled by the queue. My load tests show the API ingesting ~5,900 documents/second while Chromium renders ~63/second. That 90× gap is not a flaw. It’s the design. The queue absorbs spikes; workers drain at their natural rate; nothing gets dropped. The queue is the load balancer.<br>If you take one thing from this blueprint: never couple your accept rate to your render rate. Accept fast, render at capacity, notify on completion.<br>The render pipeline: memory bounded by design<br>A PDF can be 50 KB or hundreds of megabytes. If any stage of your pipeline holds the whole document in memory, your RAM requirement scales with the largest thing any customer ever generates: one 400-page catalog dictates your instance size for everyone. So no stage holds it:<br>Page.printToPDF({ transferMode: 'ReturnAsStream' })<br>→ IO.read(handle, { size: 1MB })<br>→ Node.js Readable (backpressure-aware)<br>→ S3 multipart upload (5 MB parts) Chrome writes the PDF to a stream as it renders. Node pulls 1 MB chunks through a backpressure-aware Readable. If S3 slows down, reads from Chrome slow down; nothing accumulates. S3 starts receiving parts before Chrome finishes rendering the last page. Memory per document is a fixed pipeline buffer, ~20 MB worst case, regardless of output size.<br>The detail most tutorials miss: transferMode: 'ReturnAsStream' on CDP’s Page.printToPDF. The default buffers the entire PDF in Chrome’s memory, then again in Node’s. It works in every demo and falls over the first time a real customer sends something big.<br>Pool your Chrome pages explicitly (mine: 16 concurrent pages across 2 worker replicas) and treat the pool as your throughput ceiling. It will be the bottleneck. That’s fine. It’s the bottleneck you can measure and scale linearly.<br>Security: separate structure from data<br>The design rests on one split: templates are structure, not data.<br>Users keep sensitive values out of templates and inject them as JSON replacements at render time. Templates live on S3 with server-side encryption; they’re layout, and layout is not the secret.<br>The JSON data payload (where names, amounts, and addresses actually live) is encrypted with AES-256-GCM before it touches storage. A fresh key is HKDF-derived per payload from the master secret and a random salt, used in memory, never persisted. There is no key table to...