Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iuriiย ships.

Iurii Rogulia, IT partner for business & fractional CTO. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland ๐Ÿ‡ซ๐Ÿ‡ฎ

[email protected]
  1. Home
  2. /
  3. Blog
  4. /
  5. Batch PDF Generation in Node.js Without Running Out of Memory

Iuriiย Automates: Batch PDF Generation in Node.js Without Running Out of Memory

Generating 5,000 PDFs with Promise.all(map) will OOM your process. Here's the streaming, concurrency-limited approach that survives.

August 12, 2026ยท 9 min read

Batch PDF generation in Node.js without OOM: concurrency limits, back-pressure, releasing renderers between docs, and why Promise.all(map) fails at scale.

Stack

Node.jsTypeScript

Libraries

react-pdfPuppeteerp-limit

Topics

PDFDocument AutomationPerformanceArchitecture
Batch PDF Generation in Node.js Without Running Out of Memory

On this page

  • Why Promise.all(map) Runs Out of Memory
  • Bound the Concurrency
  • Picking the number
  • Release the Renderer Between Documents
  • Stream, Don't Accumulate
  • What This Looks Like in Practice
  • Where This Doesn't Apply

Generating one PDF is easy. Generating one PDF per request, on demand, is also easy โ€” I've done it for 31 locales streamed straight out of a route handler. The problem starts when someone asks you to generate five thousand of them at once: a nightly statement run, a year-end report for every customer, a bulk export of invoices.

The obvious code is one line, and it will take your process down:

// This will OOM. Do not ship it.
const pdfs = await Promise.all(customers.map((c) => generateStatement(c)));

This isn't a PDF-library problem. It's a memory and throughput problem that happens to involve PDFs. The same mistake sinks image processing, report exports, and any batch that maps an async, memory-heavy operation over a large list. This article is about doing the batch part right โ€” not about which renderer to pick. If you're still choosing a tool, I wrote a separate Puppeteer vs react-pdf comparison for that decision.

Why Promise.all(map) Runs Out of Memory

Promise.all does not process items in sequence. It starts every promise in the array immediately, then waits for all of them. customers.map(generateStatement) doesn't create a queue of 5,000 pending tasks โ€” it creates 5,000 running tasks.

Each running PDF generation holds memory for the duration:

  • the source data loaded for that document,
  • the layout tree the renderer builds in memory,
  • the output buffer accumulating the finished bytes,
  • and, if you use a browser engine, an open page in a Chromium process.

A single 4-page A4 document with images and tables is not large. But multiply the peak footprint of one generation by 5,000 concurrent generations and you are asking Node to hold all of it at once. Node's default heap is capped (around 1.5โ€“2 GB on a 64-bit build unless you raise --max-old-space-size), and a browser engine lives in native memory outside that heap entirely. You hit the ceiling, V8 burns its last cycles on garbage collection, and the process dies with JavaScript heap out of memory โ€” or the OOM killer takes it first.

The failure is worse in a constrained container. This site's own CV PDF is prebuilt at build time with @react-pdf/renderer specifically to keep the production container under its 1 GiB memory limit โ€” a single document already forced that decision. A naive batch of thousands doesn't stand a chance in the same environment.

The fix is not "add more RAM". The fix is to never hold more than a handful of documents in flight at once.

Bound the Concurrency

The core idea: process the list with a fixed number of workers, not all at once. Say 4 documents in flight at any moment, regardless of whether the list has 50 or 50,000 entries. Peak memory becomes a function of your concurrency limit, not your input size โ€” which means it's constant and predictable.

You don't need a queue server for this. A concurrency limiter is enough. p-limit is the smallest, most boring tool for the job:

import pLimit from "p-limit";
 
const limit = pLimit(4); // never more than 4 running at once
 
async function generateAll(customers: Customer[]): Promise<void> {
  await Promise.all(
    customers.map((customer) =>
      limit(async () => {
        const pdf = await generateStatement(customer);
        await writeStatement(customer.id, pdf); // write out, then let it be collected
      })
    )
  );
}

Note what changed and what didn't. It's still Promise.all(map) on the outside โ€” but every task is now wrapped in limit(). p-limit only lets 4 of the wrapped functions actually run; the rest are held as cheap closures until a slot frees up. A pending closure costs almost nothing. A running PDF generation costs megabytes. That difference is the whole point.

Equally important: each task writes its result out and returns nothing. If you collect all 5,000 buffers into an array to write later, you've just moved the memory problem downstream โ€” the buffers pile up in the array instead. Write to disk, upload to storage, or stream to the response as each document finishes, then let it be garbage-collected before the next one starts.

Picking the number

There's no universal right value. It depends on the peak footprint of one generation and how much memory you're allowed to use. Start low โ€” 2 to 4 โ€” and measure. Watch RSS while the batch runs:

setInterval(() => {
  const { rss, heapUsed } = process.memoryUsage();
  console.log({
    rssMB: Math.round(rss / 1024 / 1024),
    heapMB: Math.round(heapUsed / 1024 / 1024),
  });
}, 1000);

If RSS stays flat across the whole run, your concurrency is safe and you can try raising it for throughput. If RSS climbs steadily and never comes back down, you have a leak โ€” something is being retained between documents. Find it before you raise the limit, because a higher limit only makes a leak fail faster.

Release the Renderer Between Documents

Concurrency limiting fixes the "too many at once" problem. There's a second problem specific to browser-based generation: the engine holds native memory that the JS heap can't see, and it accumulates if you don't release it.

With @react-pdf/renderer this is mostly a non-issue โ€” it's a pure-JS renderer with no external process, so each renderToBuffer call produces a buffer and the layout tree becomes garbage the moment the call returns. Bound the concurrency, write each buffer out, and you're done.

With Puppeteer it's different, because a Chromium process sits outside V8. The pattern that works at scale is: one browser, one page per document, close the page every time.

import puppeteer, { Browser } from "puppeteer";
import pLimit from "p-limit";
 
async function generateBatchPuppeteer(reports: ReportInput[]): Promise<void> {
  const browser: Browser = await puppeteer.launch({
    headless: true,
    args: ["--no-sandbox", "--disable-dev-shm-usage"],
  });
 
  const limit = pLimit(3); // Chromium pages are heavier โ€” keep this low
 
  try {
    await Promise.all(
      reports.map((report) =>
        limit(async () => {
          const page = await browser.newPage();
          try {
            await page.setContent(renderHtml(report), {
              waitUntil: "networkidle0",
            });
            const pdf = await page.pdf({ format: "A4", printBackground: true });
            await writeReport(report.id, Buffer.from(pdf));
          } finally {
            await page.close(); // release the page's memory every single time
          }
        })
      )
    );
  } finally {
    await browser.close();
  }
}

Two rules do the heavy lifting here.

Reuse the browser, not the page. Launching Chromium takes a second or two and allocates a lot; doing it per document is both slow and wasteful. But a single page that renders thousands of documents leaks โ€” retained DOM, detached nodes, whatever the last render left behind. So the browser is created once, and a fresh page is opened and closed for each document. Pages are cheap to create; keeping them open is what costs you.

Close the page in finally, unconditionally. If a page.pdf() call throws and you skip page.close(), that page stays open inside Chromium and its memory is never reclaimed. Do that a few hundred times in a batch and the browser process bloats until it's killed. The finally block isn't defensive decoration โ€” it's what keeps the batch alive.

For a very long-running batch, browsers can still creep upward over tens of thousands of documents. A pragmatic mitigation is to recycle the browser periodically โ€” close and relaunch it every N documents โ€” which resets native memory to a clean baseline. Only reach for that if you actually observe the creep; don't add it speculatively.

Related service

Automation & Workflows

Batch document jobs that need to run unattended on a schedule โ€” invoices, statements, exports โ€” and stay inside a fixed memory budget are exactly the kind of automation I build and operate.

More about this service โ†’

Stream, Don't Accumulate

Even with bounded concurrency, there's one more place memory hides: the results. If your batch produces a single combined artifact โ€” a ZIP of all PDFs, or a multi-document merge โ€” the naive version builds the whole thing in memory before writing a byte.

The alternative is back-pressure: pull the next item only when the writer is ready for it. Node streams give you this for free. Instead of mapping the whole list, process it as an async iterator and let the destination throttle the pace:

import { pipeline } from "node:stream/promises";
import { Readable } from "node:stream";
 
async function* pdfChunks(customers: Customer[]) {
  for (const customer of customers) {
    const pdf = await generateStatement(customer);
    yield pdf; // one document at a time; nothing accumulates
    // pdf goes out of scope here and becomes collectable
  }
}
 
await pipeline(
  Readable.from(pdfChunks(customers)),
  zipArchiveWriter // consumes at its own pace; generator waits
);

The generator produces exactly one document, hands it to the writer, and doesn't start the next until the writer has taken it. If the writer is slow โ€” a network upload, a disk under load โ€” the generator naturally pauses. That's back-pressure. Memory stays flat no matter how many customers are in the list, because at any instant there's one document in flight, not five thousand.

This trades throughput for a hard memory ceiling: a serial generator is slower than 4 parallel workers. In practice you combine the two โ€” bounded concurrency for speed, streaming to the destination for the memory ceiling โ€” but if you have to pick one property to guarantee, "the job finishes" beats "the job is fast."

What This Looks Like in Practice

The shape of a batch job that survives production is consistent regardless of the renderer:

  • Concurrency is bounded to a small number, measured against real memory, not guessed.
  • Each document is written out and released as it finishes โ€” no array of buffers waiting at the end.
  • The renderer instance is reused, its per-document handle is not. One browser, one page per doc, closed in finally. One renderToBuffer call per doc for the pure-JS path.
  • The final artifact is streamed, so combining thousands of documents doesn't require holding thousands of documents.
  • Memory is observed while it runs. A flat RSS line is the acceptance test. A climbing one is a bug, not a reason to buy RAM.

None of this is exotic. It's the difference between a batch that finishes quietly at 3 AM and one that OOMs at document 812, leaving half the run missing โ€” which nobody notices until a customer asks where their statement is.

Where This Doesn't Apply

If you're generating one document per request, none of this matters โ€” the request lifecycle already bounds your concurrency, and a single generation fits comfortably in memory. Reach for concurrency limits and streaming when the batch size is unbounded or large enough that the peak footprint of "all of them at once" exceeds what you're allowed to use. For a few dozen documents, Promise.all(map) is fine. The trouble is specifically at scale, and the fix costs a few lines.


I build and operate document pipelines that run unattended โ€” bulk invoice and statement generation, scheduled report exports, on-demand PDFs across 30+ locales โ€” the kind that has to finish inside a fixed memory budget without a human watching. If you have a batch job that falls over at scale, or you're about to build one and want it to survive the first real run, get in touch. I take on automation and workflow projects and longer engagements.

Iurii Rogulia

Working on something like this?

Automation & Workflows

Need thousands of documents generated on a schedule โ€” invoices, reports, statements โ€” without the job falling over? Batch pipelines that stay inside their memory budget are exactly what I build.

More about this service

Relevant client work

View all projects
Pikkuna โ€” E-commerce for Vinyl Curtains & PVC Products
Pikkuna โ€” E-commerce for Vinyl Curtains & PVC Products
October 12, 2024
Pikkuna โ€” E-commerce for Vinyl Curtains & PVC Products

International e-commerce platform with 30 locales, product configurators, AI chatbot, and fully automated order flow: Stripe โ†’ Zoho CRM โ†’ Airtable โ†’ Mailgun โ†’

pi-pi.ee โ€” i18n B2B Presentation PDF Generator
pi-pi.ee โ€” i18n B2B Presentation PDF Generator
March 4, 2026
pi-pi.ee โ€” i18n B2B Presentation PDF Generator

On-demand PDF presentation generator for 31 European markets โ€” one codebase, one URL pattern, streamed directly from a Next.js route handler with zero storage

pi-pi.ee โ€” B2B E-commerce for Waterless Urinal Systems
pi-pi.ee โ€” B2B E-commerce for Waterless Urinal Systems
January 17, 2026
pi-pi.ee โ€” B2B E-commerce for Waterless Urinal Systems

i18n B2B e-commerce platform for waterless urinal products across 32 European countries with automated VAT handling, PDF invoicing, and CRM integration.

What clients say

โ€œ

I'd built most of our MVP with Cursor and it looked finished โ€” it compiled, the tests were green, the demo worked. It just wouldn't survive real users. Iurii took it over and found what I couldn't see: authentication handled four different ways, tests that only asserted what the code already did, and a dependency list half of which was unused. He didn't rewrite it from scratch โ€” he told me honestly what was salvageable, ripped out the dead code, and got it to something a real team could build on. Two weeks and it went from 'looks done' to actually shippable.

Sebastian Falk ๐Ÿ‡ธ๐Ÿ‡ช

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
โ€œ

We publish 20-30 news articles per day and indexing latency was killing us โ€” by the time Google crawled a story, the news cycle had moved on. Iurii's audit covered IndexNow setup, a sitemap that was hitting size limits and dropping URLs silently, and canonical tags that were inconsistent between AMP and non-AMP versions. The IndexNow integration alone moved median time-to-index for Bing from days to under an hour. Report was direct, no fluff, exactly what we needed.

Andrei Popescu ๐Ÿ‡ท๐Ÿ‡ด

Engineering Manager

Topics

SEOIndexNowArchitecturePerformance
โ€œ

I was starting a new project and spent two weeks reading comparisons online and getting nowhere. One hour with Iurii and I had a clear answer. He asked the right questions, cut through the noise, and gave me a real recommendation โ€” not 'it depends'.

Marcus Chen ๐Ÿ‡บ๐Ÿ‡ธ

Stack

TypeScript

Topics

Architecture

Related articles

Puppeteer vs react-pdf: Node.js PDF Generation Compared (2026)
August 20, 2025ยท 16 min
Puppeteer vs react-pdf: Node.js PDF Generation Compared (2026)

Puppeteer vs @react-pdf/renderer for Node.js PDF generation, decided by production use, not benchmarks: which survives Vercel's serverless limits, which

Stack

Next.jsTypeScriptNode.js

Libraries

Puppeteerreact-pdf

Services

Vercel

Topics

PDFE-commerce
IndexNow in Next.js: Instant Indexing After Every Deploy
April 14, 2026ยท 18 min
IndexNow in Next.js: Instant Indexing After Every Deploy

IndexNow implementation guide for Next.js: key generation, TypeScript client with retry logic, GitHub Actions workflow, and pitfalls that break submissions

Stack

TypeScriptNode.js

Services

GitHub Actions

Topics

SEOAutomationPerformanceArchitecture
UUID v7 vs v4: PostgreSQL Performance Benchmark
March 11, 2026ยท 16 min
UUID v7 vs v4: PostgreSQL Performance Benchmark

UUID v7 vs v4 in PostgreSQL: why random UUIDs fragment B-tree indexes, how UUID v7 fixes it, benchmarks at 5M rows, and Drizzle ORM migration steps.

Stack

TypeScriptNode.js

Libraries

uuidulidnanoidDrizzle ORM

Databases

PostgreSQL

Topics

ArchitectureSaaSPerformance
Redis Rate Limiting in Node.js: Sliding Window Algorithm
October 10, 2025ยท 15 min
Redis Rate Limiting in Node.js: Sliding Window Algorithm

Redis rate limiting for APIs: sliding window ZADD with Lua atomicity, in-memory fallback, multi-tier limits by IP and API key, and developer-friendly response

Stack

TypeScriptNode.jsHono

Libraries

ioredis

Databases

Redis

Topics

SaaSAPIPerformanceArchitecture
Preventing Overselling: Inventory Locks Under Concurrent Checkouts
July 31, 2026ยท 13 min
Preventing Overselling: Inventory Locks Under Concurrent Checkouts

Prevent overselling under concurrent checkouts: reservations vs hard decrements, SELECT FOR UPDATE, deadlock-safe multi-line carts, and the payment window.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis

Topics

E-commercePaymentsArchitectureSaaS