Streaming Output
This guide covers the different ways to save a PDF document and how to use streaming for memory-efficient PDF generation.
Output Methods
modern-pdf-lib provides three methods for saving a PDF document:
| Method | Return Type | Best For |
|---|---|---|
pdf.save() | Promise<Uint8Array> | General use, small to medium documents |
pdf.saveAsStream() | ReadableStream<Uint8Array> | Large documents, server responses |
pdf.saveAsBlob() | Promise<Blob> | Browser downloads and display |
save() — Full Buffer
The simplest method. The entire PDF is generated in memory and returned as a Uint8Array:
const bytes = await pdf.save();This is the right choice when:
- The document fits comfortably in memory
- You need the complete byte array (e.g., for hashing or encryption)
- You are writing to a file in one shot
import { writeFile } from 'node:fs/promises';
const bytes = await pdf.save();
await writeFile('report.pdf', bytes);saveAsStream() — Streaming
Returns a standard ReadableStream<Uint8Array> that emits PDF chunks as they are generated. The document is never fully buffered in memory.
const stream = pdf.saveAsStream();This is the right choice when:
- Generating large documents (hundreds of pages, many images)
- Serving PDFs over HTTP where you want to start sending bytes immediately
- Working in memory-constrained environments (Cloudflare Workers, edge functions)
saveAsBlob() — Browser Blob
Returns a Blob with the MIME type application/pdf. This is a convenience wrapper around save() designed for browser use:
const blob = await pdf.saveAsBlob();
const url = URL.createObjectURL(blob);
window.open(url);Streaming to a File (Node)
Pipe the stream to a file using Node's writable stream:
import { createWriteStream } from 'node:fs';
import { Writable } from 'node:stream';
const pdf = createPdf();
// ... add pages and content ...
const stream = pdf.saveAsStream();
const fileStream = createWriteStream('large-report.pdf');
await stream.pipeTo(Writable.toWeb(fileStream));
console.log('Done writing PDF');You can also consume the stream manually:
import { open } from 'node:fs/promises';
const stream = pdf.saveAsStream();
const file = await open('output.pdf', 'w');
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
await file.write(value);
}
} finally {
await file.close();
}Streaming to an HTTP Response
Cloudflare Workers
export default {
async fetch(): Promise<Response> {
const pdf = createPdf();
// ... build document ...
return new Response(pdf.saveAsStream(), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="report.pdf"',
'Cache-Control': 'no-store',
},
});
},
};Node with http Module
import { createServer } from 'node:http';
import { Readable } from 'node:stream';
createServer(async (req, res) => {
const pdf = createPdf();
// ... build document ...
res.writeHead(200, {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="report.pdf"',
'Transfer-Encoding': 'chunked',
});
const stream = pdf.saveAsStream();
const nodeStream = Readable.fromWeb(stream);
nodeStream.pipe(res);
}).listen(3000);Node with Express/Hono/Fastify
Most Node frameworks accept a ReadableStream or a Node Readable as a response body:
// Express
app.get('/report.pdf', async (req, res) => {
const pdf = createPdf();
// ... build document ...
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="report.pdf"');
const stream = pdf.saveAsStream();
const nodeStream = Readable.fromWeb(stream);
nodeStream.pipe(res);
});Memory Considerations for Large Documents
Why Streaming Matters
When generating a 500-page report with embedded images, save() must hold the entire PDF in memory at once. With saveAsStream(), pages are serialized and flushed incrementally:
| Method | Peak Memory (500 pages, 50 images) |
|---|---|
save() | ~120 MB |
saveAsStream() | ~15 MB |
TIP
For documents with more than 50 pages or more than 10 MB of embedded images, prefer saveAsStream().
Memory Tips
- Embed images before adding pages. Image data is compressed during embedding, not during save.
- Reuse font and image references. Embedding the same image twice doubles memory usage.
- Use JPEG for photographs. JPEG passthrough avoids decompression and recompression.
- Enable WASM. The WASM compressor uses less memory than the pure-JS fallback.
Backpressure Handling
The ReadableStream returned by saveAsStream() respects backpressure. If the consumer is slower than the producer (for example, a slow network connection), the stream pauses PDF generation until the consumer is ready for more data.
This is handled automatically by the Web Streams API. No special configuration is needed:
// Backpressure is handled automatically by pipeTo()
await pdf.saveAsStream().pipeTo(writableStream);If you are consuming the stream manually with a reader, the backpressure is applied naturally by the await reader.read() call:
const reader = pdf.saveAsStream().getReader();
while (true) {
// This await creates natural backpressure — the stream
// pauses until we call read() again
const { done, value } = await reader.read();
if (done) break;
await slowNetworkWrite(value);
}Monitoring Stream Progress
You can wrap the stream with a TransformStream to monitor progress:
let totalBytes = 0;
const progress = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
totalBytes += chunk.byteLength;
console.log(`Written ${(totalBytes / 1024).toFixed(1)} KB`);
controller.enqueue(chunk);
},
});
await pdf.saveAsStream().pipeThrough(progress).pipeTo(destination);
console.log(`Total: ${(totalBytes / 1024).toFixed(1)} KB`);Comparison Summary
save() ──► Uint8Array Simple, entire PDF in memory
saveAsStream() ──► ReadableStream Incremental, low memory
saveAsBlob() ──► Blob Browser convenienceChoose save() for simplicity, saveAsStream() for scalability, and saveAsBlob() for browser downloads.