The bill that started this
I was paying $40/month for a PDF generation API to power a tiny internal invoicing tool for a client project. Forty bucks a month to convert some JSON into a PDF. That’s it. That’s the whole service.
I finally sat down on a Saturday to see if I could kill that subscription. Three weekends later, not only did I kill it — the replacement is faster than the API ever was, because there’s no network round-trip at all.
This post is the log of how it went, in the order I actually hit the problems, not the order that makes me look competent.
Attempt #1: jsPDF on the main thread (it worked, until it didn’t)
First pass was the obvious one — jsPDF running directly in the click handler:
function generateInvoice(data) {
const doc = new jsPDF();
doc.text(data.clientName, 20, 20);
data.lineItems.forEach((item, i) => {
doc.text(`${item.description} — $${item.amount}`, 20, 40 + i * 10);
});
doc.save('invoice.pdf');
}
Enter fullscreen mode Exit fullscreen mode
Fine for a 3-line invoice. Once I tested with a 40-line-item invoice (a real client sent me one to test against), the tab froze for almost two full seconds. Not crashed — frozen. Scroll didn’t work, buttons didn’t respond, and on a mid-range Android phone it was closer to five seconds.
The main thread doing synchronous PDF math while also being responsible for painting the UI is exactly the kind of thing that looks fine in a demo and falls apart the moment a real user pastes in real data.
Attempt #2: move it to a Web Worker
Web Workers get talked about like they’re this exotic tool for WASM and video processing. They’re also just… a really good fit for “expensive synchronous work that a user is waiting on.” I’d never reached for one before this project, mostly out of habit.
The tricky part isn’t the worker itself, it’s that jsPDF assumes it has access to document and window in a couple of code paths (font metrics, mostly), which don’t exist inside a worker. I ended up switching to pdfkit compiled for the browser, which is worker-friendly out of the box.
// pdf.worker.js
import PDFDocument from 'pdfkit/js/pdfkit.standalone.js';
import blobStream from 'blob-stream';
self.onmessage = (e) => {
const { invoice } = e.data;
const doc = new PDFDocument({ size: 'A4', margin: 50 });
const stream = doc.pipe(blobStream());
doc.fontSize(20).text(invoice.clientName, { align: 'left' });
doc.moveDown();
invoice.lineItems.forEach(item => {
doc.fontSize(11).text(`${item.description}`, { continued: true });
doc.text(`$${item.amount.toFixed(2)}`, { align: 'right' });
});
doc.fontSize(14).text(`Total: $${invoice.total.toFixed(2)}`, { align: 'right' });
doc.end();
stream.on('finish', () => {
const blob = stream.toBlob('application/pdf');
self.postMessage({ status: 'done', blob });
});
};
Enter fullscreen mode Exit fullscreen mode
// main thread
const worker = new Worker(new URL('./pdf.worker.js', import.meta.url));
function generateInvoice(invoice) {
return new Promise((resolve) => {
worker.postMessage({ invoice });
worker.onmessage = (e) => {
if (e.data.status === 'done') resolve(e.data.blob);
};
});
}
Enter fullscreen mode Exit fullscreen mode
Same 40-line invoice, same laptop: zero dropped frames. The UI stays fully interactive the entire time because the worker is a genuinely separate thread — it’s not just async, it’s parallel.
Thing I got wrong the first time: I forgot workers can’t be spun up from a file:// context or without a proper module bundler setup, and I burned about 40 minutes assuming my code was broken when it was actually a Vite config issue with worker module resolution. If you hit a silent worker failure with nothing in the console, check your bundler’s worker plugin config before you check your logic.
The offline problem nobody asked for (but I wanted anyway)
Once the PDF generation was fully client-side, I realized the whole app could work with no network at all — except drafts were still sitting in a useState/Svelte store that vanished on refresh.
IndexedDB is the obvious answer here, but the raw API is genuinely unpleasant to write by hand. I used idb (a tiny promise wrapper) instead of hand-rolling transaction callbacks:
import { openDB } from 'idb';
const dbPromise = openDB('invoices-db', 1, {
upgrade(db) {
db.createObjectStore('drafts', { keyPath: 'id' });
},
});
export async function saveDraft(invoice) {
const db = await dbPromise;
await db.put('drafts', invoice);
}
export async function getAllDrafts() {
const db = await dbPromise;
return db.getAll('drafts');
}
Enter fullscreen mode Exit fullscreen mode
Paired with a service worker caching the app shell, the tool now works on a plane with no wifi, which was never a requirement — I just liked the idea that an invoicing tool shouldn’t care whether your internet is working. Freelancers filling these out from a job site with bad signal was the actual use case that made this worth doing, once I thought about it.
Things that bit me that I didn’t expect
Font embedding bloats the bundle fast. Embedding a single custom font (for brand consistency on the invoice) added close to 300KB to the worker bundle. I switched to subsetting the font to only the character set actually used (fontkit + a subsetting step at build time) and got that down under 40KB.
blob-stream output isn’t a real Blob until finish fires. I tried reading the blob immediately after doc.end() more than once before realizing the stream is asynchronous even though nothing about the API signals that clearly.
Safari’s IndexedDB implementation in private browsing mode silently caps storage at a tiny quota. Drafts would save fine in a normal tab and then quietly fail in private mode with no error thrown. I added a try/catch around every saveDraft call and a visible “couldn’t save — are you in private browsing?” banner as a fallback, since there’s no reliable way to detect private mode ahead of time anymore.
Where it landed
PDF generation: ~180ms for a 40-line invoice, entirely off the main thread.
Works fully offline after first load.
Zero backend, zero monthly PDF API bill.
Total JS shipped to the client (worker included): ~85KB gzipped.
What I’d tell someone starting this today
If your app does any synchronous, CPU-heavy work in response to a user action — PDF generation, image processing, large JSON parsing, CSV crunching — try the Web Worker before you reach for a paid API or a backend endpoint. It’s not as scary as it sounds, and libraries meant for Node (like pdfkit) often work in a worker context even when they choke on window-dependent assumptions in the main thread.
The offline piece was the bonus I didn’t plan for, and it turned out to be the feature actual users mentioned first when I asked for feedback. Sometimes the thing you build because it’s technically interesting is the thing that ends up mattering to the person using it.
Happy to share the full repo structure or the Vite worker config in the comments if it’d help — that config took longer to get right than everything else in this post combined.