Guide

The 4.5 MB request limit that breaks a working PDF merge on Vercel

A PDF merge endpoint that works every time on a laptop can start returning 413 the moment it moves onto Vercel, with no body in the response to explain why. The input files look reasonable, the handler code hasn't changed, and the identical request against a local dev server still succeeds. The cause is a cap on the whole request body that Vercel enforces before a serverless function's own code runs at all, so no amount of debugging inside the handler will find it. The handler never gets invoked.

What actually happens at 4.5 MB

Vercel's Serverless Functions reject any request body over 4.5 MB (4,718,592 bytes) with a 413 and the status FUNCTION_PAYLOAD_TOO_LARGE, confirmed against a live production deployment. That rejection happens at the platform layer, ahead of the function's own code, so a try/catch wrapped around the handler's body-parsing logic has nothing to catch; the request never reaches it. The limit applies to the whole body regardless of how it's packaged, so a multipart upload with three PDFs attached counts all three files together against one shared 4.5 MB ceiling, not against three separate per-file limits.

Why local testing never catches it

A local dev server rarely enforces any size limit on its own. Express and Node's built-in http server will accept a multipart body of pretty much any size by default, so a handler tested with curl against localhost:3000 swallows a 10 MB upload without complaint. Deploy the same code to Vercel and the identical curl command against the production URL comes back 413 before the handler's first line runs. The gap between working in dev and failing in prod comes from a platform behavior that only exists once the request leaves localhost, so a local test suite built around the dev server will pass cleanly right up until the first real deploy with a big enough file.

Size the request before you send it

Since the rejection carries no detail about which file or how far over the limit the request was, the cheaper fix is a client-side check that runs before the request goes out at all, so a caller gets a specific error instead of an opaque 413:

const MAX_REQUEST_BYTES = 4.5 * 1024 * 1024;

function assertFitsRequestLimit(files) {
  const total = files.reduce((sum, f) => sum + f.size, 0);
  if (total >= MAX_REQUEST_BYTES) {
    throw new Error(
      `request would be ${(total / 1024 / 1024).toFixed(1)} MB; ` +
      `the platform rejects anything at or above 4.5 MB`,
    );
  }
}

That check needs to run against the exact bytes going over the wire, including multipart boundaries and any other form fields riding along with the files, since those count toward the same 4.5 MB ceiling too.

Chaining calls instead of raising the limit

There's no project setting on Vercel that raises this particular cap; it's fixed at the platform level for Serverless Functions on every plan. For a merge of more than a handful of PDFs, the practical fix is to stop trying to fit the whole job into one request and chain calls instead: merge the first batch that fits under the limit, feed that single merged result back in as one more input alongside the next batch, and keep going until every file has been folded in.

async function mergeAll(apiKey, pdfBuffers, maxBytes = 4 * 1024 * 1024) {
  let merged = null;
  let batch = [];
  let batchBytes = 0;

  for (const buf of pdfBuffers) {
    if (batch.length && batchBytes + buf.length > maxBytes) {
      merged = await mergeBatch(apiKey, merged ? [merged, ...batch] : batch);
      batch = [];
      batchBytes = 0;
    }
    batch.push(buf);
    batchBytes += buf.length;
  }

  return mergeBatch(apiKey, merged ? [merged, ...batch] : batch);
}

async function mergeBatch(apiKey, buffers) {
  const form = new FormData();
  for (const buf of buffers) {
    form.append("pdf", new Blob([buf]), "input.pdf");
  }
  const res = await fetch("https://pdfops.dev/api/merge", {
    method: "POST",
    headers: { "X-API-Key": apiKey },
    body: form,
  });
  if (!res.ok) throw new Error(`merge batch failed: ${res.status}`);
  return Buffer.from(await res.arrayBuffer());
}

Each call stays well under the per-request ceiling because the batch size is capped before it's sent, and the running merged result only ever travels as one file per step instead of growing the whole input set into a single oversized request.

How PDFops surfaces this instead of a bare 413

PDFops' own /api/merge and /api/fill-form sit on the same 4.5 MB line Vercel enforces, but reject early with a JSON body instead of an empty platform 413:

{
  "error": "request_too_large",
  "details": "request body exceeds 4.5 MB"
}

That check runs as middleware before the body is buffered, so a caller gets request_too_large with a clear reason instead of a bare 413 with nothing in the body to act on. The per-PDF cap sits at 4 MB and the merge endpoint takes up to 20 files, so the same batching approach above applies directly: chain /api/merge calls for anything that won't fit in one request.

Try it

A free key from /docs/signup includes 250 calls a month with no card, enough to try a batch of real PDFs against /api/merge and see the 4.5 MB boundary and the request_too_large response firsthand.