·5 min read

Generating PDFs in a Vercel serverless function without the pain

Bundling headless Chromium into a Vercel serverless function is the hard way to generate PDFs: cold starts stretch toward the execution timeout, the deployment's compressed size budget gets tight fast once you add Chromium plus fonts, and CJK or emoji glyphs silently render as empty boxes because Vercel's runtime doesn't ship system fonts. The straightforward fix is to not run a browser in your function at all — call a PDF-generation API from a normal Route Handler and let it own the browser process, the fonts, and the render.

Why bundling Chromium into a Vercel function hurts

The common pattern is @sparticuz/chromium plus playwright-core (or puppeteer-core) inside an API route: launch a headless browser, load your HTML, print to PDF, return the buffer. It works locally. In production on Vercel it runs into three separate walls at once.

Bundle size

Vercel functions have a compressed deployment size ceiling, and a Chromium binary alone eats a meaningful chunk of it before you've added your own dependencies. Add a font-heavy bundle (see below) and a normal Next.js app's node_modules, and it's easy to trip the limit on a route that otherwise has nothing to do with rendering.

Cold starts

A cold invocation has to unpack the function, spin up the Chromium binary, and only then start rendering. That launch overhead stacks directly on top of your execution timeout. On Vercel's shorter timeout tiers this is genuinely tight; even on higher tiers, a user who requests an invoice and watches a spinner for several seconds because the function happened to be cold is a bad experience you didn't sign up to debug.

Fonts

This is the one that bites people after launch, not during development. Vercel's serverless runtime doesn't include the system fonts installed on your Mac or your CI box. Anything outside basic Latin — CJK characters, emoji, some accented glyphs in certain font stacks — can render as tofu boxes in production while looking fine locally. The fix is to bundle font files yourself and configure fontconfig inside the function, which adds more size and more moving parts to a component whose only job was supposed to be "make a PDF."

None of this is a Vercel bug. It's the accurate cost of running a full browser inside a function optimized for small, short-lived, stateless work. If you want the deep version of this failure mode, the headless Chromium PDF glossary entry covers what a browser-based renderer actually needs at runtime.

The alternative: don't run a browser in your function

If PDF generation is a REST call instead of a subprocess, the problem set collapses. Your Route Handler does what Route Handlers are good at — parse a request, call an external service, stream back a response — and the browser, the fonts, and the Chromium process management live somewhere that's built for them.

Here's a working Next.js 15 Route Handler that renders an HTML invoice to PDF via Docweave in Next.js and streams the result back to the client:

// app/api/invoice/route.ts
import { NextRequest, NextResponse } from 'next/server';

export const runtime = 'nodejs';

export async function POST(req: NextRequest) {
  const { html, invoiceId } = await req.json();

  const res = await fetch('https://docweave.dev/api/v1/pdf', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.DOCWEAVE_API_KEY}`,
      'Content-Type': 'application/json',
      Accept: 'application/pdf',
    },
    body: JSON.stringify({
      source: { type: 'html', html },
      idempotencyKey: `invoice-${invoiceId}`,
    }),
  });

  if (!res.ok) {
    const error = await res.json().catch(() => ({ message: 'unknown error' }));
    return NextResponse.json({ error }, { status: 502 });
  }

  const buffer = Buffer.from(await res.arrayBuffer());
  return new NextResponse(buffer, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="invoice-${invoiceId}.pdf"`,
    },
  });
}

That's the whole route. No @sparticuz/chromium, no font bundling, no browser lifecycle to manage. The function's only dependency is fetch.

What this buys you

  • **Small, fast functions.** With no browser binary and no font files in the bundle, cold starts are ordinary Node cold starts, not "launch Chromium" cold starts.
  • **Fonts and glyphs handled elsewhere.** CJK, emoji, and web fonts are the render service's problem, tested against its own Chromium fleet, not re-solved per project.
  • **No timeout math.** Your route awaits one HTTP call. You're not trying to fit "cold start + browser launch + navigation + print" inside a single invocation's execution budget.
  • **Idempotency for free retries.** Serverless platforms sometimes retry a request that appeared to hang. Passing a stable idempotencyKey (an invoice ID, an order ID) means a retried invocation returns the same PDF instead of rendering — and billing — it twice.

This same shape works for any document that starts life as a template rather than raw HTML — see invoices, receipts, and reports for the request-body variants.

When bundling your own Chromium still makes sense

It's a fair trade-off in a few situations: you're running a long-lived server (not serverless) where cold starts don't apply, you have very high volume and want to avoid per-document costs entirely, or you need a rendering pipeline so custom that no API's feature set covers it. Puppeteer and Playwright are solid tools, and self-hosting is a legitimate choice if you're willing to own font provisioning, security patching, and scaling the browser process yourself. What doesn't hold up well is doing that *inside* a serverless function that's billed and sized for short, stateless work — that's the specific combination that causes the pain above. For a broader look at where the API route saves time versus a self-hosted renderer, see the best PDF generation APIs compared side by side, including alternatives like PDFShift and DocRaptor.

If an AI agent is the one generating the PDF

The same problem shows up one layer up the stack when a Vercel-hosted agent workflow needs to hand a model the ability to produce a document, without also handing it a Chromium binary to manage. The open-source MCP server wraps the identical render path as a tool call:

generate_pdf({
  source: { type: 'url', url: 'https://example.com/invoice/123' },
  outputPath: './invoice.pdf',
});

More on wiring that up in agent frameworks is in the PDF API for AI agents guide, and the full request/response shape for both approaches is in the docs.

Either way — REST route or MCP tool — the pattern is the same: keep the browser out of your function, and check pricing against how many documents you're actually generating a month before you decide it's worth owning Chromium yourself.

FAQ

Why does bundling Chromium blow up my Vercel function's size so much?

A headless Chromium binary is large on its own, and once you add the font files needed to render non-Latin text correctly, plus your normal application dependencies, it's easy to approach Vercel's compressed function size limit — especially if the PDF route shares a bundle with unrelated app code.

Will calling an external PDF API add noticeable latency compared to bundling Chromium in-process?

There's network round-trip time, but you're also removing the in-process Chromium launch, which on a cold function invocation is often the larger cost. In practice, a warmed-up render service reached over HTTP tends to be comparable to or faster than a cold in-function browser launch.

What happens if Vercel retries my function invocation and I've already sent the render request?

Pass a stable idempotencyKey (for example, the invoice or order ID) with every render call. A retried request with the same key returns the original result instead of rendering — and billing — the document a second time.

Can I still self-host Chromium on Vercel if I want to?

Yes, with @sparticuz/chromium and careful font/fontconfig setup it's workable, particularly at low volume or when you need a fully custom rendering pipeline. The trade-off is that you own cold-start latency, bundle size, and font correctness yourself — the exact problems an external render API removes.

Related

Generate your first PDF in minutes.

Get an API key