Streaming output
pdfnative emits PDFs as
AsyncGenerator<Uint8Array>so you can pipe them to disk, an HTTP response, or a Web Stream without buffering the whole document. New in v1.3.0: a mode in which the fully-joined PDF binary never exists in memory — see what this does and does not buy you for the exact profile.
Three streaming modes#
| Function | Memory profile | When to use |
|---|---|---|
buildDocumentPDFStream / buildPDFStream |
Assembles full binary, then yields fixed-size chunks | Simple back-pressure-friendly piping |
buildDocumentPDFStreamPageByPage / buildPDFStreamPageByPage |
Assembles full binary, yields one PDF object per chunk | Object-granular persistence / diagnostics |
buildDocumentPDFStreamTrue / buildPDFStreamTrue |
Never joins the binary — frees each part as it yields | Large documents, and anything over ~512 MB of output |
All three produce byte-identical output to buildDocumentPDFBytes() /
buildPDFBytes().
Streaming without joining the binary (v1.3.0)#
import { createWriteStream } from 'node:fs';
import { buildDocumentPDFStreamTrue } from 'pdfnative';
const out = createWriteStream('report.pdf');
for await (const chunk of buildDocumentPDFStreamTrue(params, layout, { chunkSize: 65536 })) {
out.write(chunk);
}
out.end();
Internally, the builder assembles the PDF into an array of raw parts (objects,
xref, trailer) and the generator walks that array, encoding each part to bytes
and freeing it (parts[i] = '') as soon as it is emitted.
What this does and does not buy you#
Being precise here matters, because the two claims are often conflated.
What it avoids. The joined PDF binary never exists. That removes a full
second copy of the document, and it lifts the hard ceiling that
buildDocumentPDFStream hits: that variant joins everything into one JavaScript
string, and V8 caps a single string at roughly 512 MB. Past that point it throws
regardless of how much RAM the machine has. buildDocumentPDFStreamTrue has no
such ceiling.
What it does not avoid. assembleDocumentParts() runs to completion before
the first chunk is yielded, so every part is resident at that moment. Peak memory
is therefore still proportional to total output size — roughly 2 bytes per output
character, since JavaScript strings are UTF-16. From the first yield onwards
memory falls monotonically as parts are freed, but the peak has already happened.
Two practical consequences:
- Budget for about twice your expected output size, not for a fixed ceiling. A 300 MB PDF wants roughly 600 MB of headroom.
- There is no progress signal during assembly. Most of the wall-clock time is
spent inside
assembleDocumentParts(), which yields nothing, so a percentage bar covering that phase would be invented rather than measured. Report an indeterminate state until the first chunk arrives, then switch to a byte counter. The scale playground does exactly this.
True page-by-page assembly — where peak memory is bounded by one page rather than the whole document — is not implemented yet.
HTTP response (Node)#
import { buildDocumentPDFStreamTrue } from 'pdfnative';
app.get('/report.pdf', async (req, res) => {
res.setHeader('Content-Type', 'application/pdf');
for await (const chunk of buildDocumentPDFStreamTrue(params)) {
res.write(chunk);
}
res.end();
});
Web Streams (browser / Deno / edge)#
const stream = new ReadableStream({
async pull(controller) {
for await (const chunk of buildDocumentPDFStreamTrue(params)) {
controller.enqueue(chunk);
}
controller.close();
},
});
return new Response(stream, { headers: { 'Content-Type': 'application/pdf' } });
Options#
interface StreamOptions {
/** Bytes per yielded chunk. Default 65536 (64 KB). Clamped to 1 KB–16 MB. */
chunkSize?: number;
}
Draining to a file: streamToFile (v1.4.0)#
streamToFile() drains any AsyncGenerator<Uint8Array> — including all
three streaming modes above — straight to a file on disk in Node.js. It honours
the OS write back-pressure (awaiting the 'drain' event when the kernel buffer
fills) and supports cancellation via an AbortSignal, so a single call covers
the common "generate a large PDF to disk without buffering it" case.
import { buildDocumentPDFStreamTrue, streamToFile } from 'pdfnative';
const { bytesWritten, path } = await streamToFile(
buildDocumentPDFStreamTrue(params),
'report.pdf',
);
console.log(`Wrote ${bytesWritten} bytes to ${path}`);
Cancellation#
const ac = new AbortController();
setTimeout(() => ac.abort(), 5000); // give up after 5s
await streamToFile(buildDocumentPDFStreamTrue(params), 'report.pdf', {
signal: ac.signal,
}); // rejects with the abort reason; the partial file is closed
On abort — or on any write error — streamToFile releases the file descriptor
and removes the partially-written file (best-effort), so a cancelled or
failed run never leaves an orphaned half-written PDF on disk.
streamToFile is Node-only — it loads node:fs lazily via a dynamic import,
so importing it in a browser or Deno bundle adds no static Node dependency. In
non-Node runtimes, drive the generator yourself with the Web Streams snippet
above.
Streaming merge & split (v1.6.0)#
The page-tree API has constant-memory variants —
streamMergedPdfs / streamSplitPdf / streamExtractPages — that emit the
assembled document as fixed-size chunks while holding only the cross-reference
offsets in memory. Each is byte-identical to its buffered counterpart and
composes with streamToFile. (Exception: with MergeOptions.encrypt the
output is AES-encrypted with fresh random IVs/salts, so repeated invocations
are structurally — not byte — identical.)
import { streamMergedPdfs, streamSplitPdf, streamToFile } from 'pdfnative';
await streamToFile(streamMergedPdfs([a, b]), 'combined.pdf');
for await (const part of streamSplitPdf(body, ranges)) {
await streamToFile(part.pdf, `part-${part.index}.pdf`); // drain each fully, in order
}
Note the honest memory profile: output bytes are never buffered, but the
source PDFs are still in-memory Uint8Arrays. See the
PDF manipulation guide for the
full API.
Constraints#
Streaming is incompatible with two features that need a second pass over the whole document. Both are validated at the boundary and throw a descriptive error:
- TOC blocks — the table of contents requires multi-pass pagination to resolve page numbers.
{pages}placeholder in header/footer templates — the total page count is unknown during progressive emission. Use{page}instead, or fall back tobuildDocumentPDFBytes().