Merging and Splitting PDFs Without Uploading Them Anywhere
How ToolRunner's PDF Merger & Splitter merges, reorders, and extracts PDF pages entirely in the browser with pdf-lib and pdf.js — no server, no upload.
by Dowon Oh
Every "merge PDF online" site works the same way: you upload your file to someone's server, their backend stitches the pages together, and you download the result. For a contract, a medical record, or a payslip, that round trip is the entire problem. You have no idea how long the file sits in their storage, who can read it, or whether "deleted after one hour" means anything. When I built the PDF Merger & Splitter for ToolRunner, the design constraint was absolute: the PDF bytes never leave the browser tab. No upload endpoint exists. This article walks through how that works — loading files as ArrayBuffers, copying pages between documents with pdf-lib, rendering thumbnails with Mozilla's pdf.js, drag-to-reorder with dnd-kit — and the constraints I actually hit along the way: encrypted files, memory pressure from thumbnail rendering, and a pdf.js import that crashes server-side prerendering unless you wall it off behind a lazy client shell.
Two libraries, two jobs
A browser PDF tool needs two fundamentally different capabilities, and no single library does both well.
The first job is manipulating the document: creating a new PDF, copying pages out of existing ones, saving the result as bytes. For this I use pdf-lib, a pure-JavaScript library that parses and writes the PDF object model directly. It runs anywhere JavaScript runs, needs no worker, and exposes a small, promise-based API: PDFDocument.load(), copyPages(), addPage(), save().
The second job is rendering pages as images so the user can see what they are reordering. pdf-lib deliberately does not render — drawing a PDF page faithfully means implementing fonts, color spaces, and the full content-stream operator set. That is what Mozilla's pdf.js is for. It is the same engine Firefox uses for its built-in viewer, and it can rasterize any page onto a <canvas>.
So the tool splits responsibilities cleanly: pdf.js paints the thumbnails you look at; pdf-lib produces the file you download. Both consume the same input — the raw bytes of the file the user dropped onto the page.
From File to ArrayBuffer, and validating before trusting
Everything starts with the browser's File API. When a user drops files onto the drop zone, each one is a File object — a handle to bytes on disk that JavaScript can read without any network involvement. The arrayBuffer() method reads those bytes into memory as an ArrayBuffer, which is exactly what both pdf-lib and pdf.js accept as input.
But I do not hand arbitrary bytes to a PDF parser blindly. Validation happens in two steps, and the first one is almost free. Every PDF file begins with the five-byte magic sequence %PDF-. Reading just those bytes catches renamed .docx files and other impostors without parsing anything:
const headerBuffer = await file.slice(0, 5).arrayBuffer();
const header = new Uint8Array(headerBuffer);
if (
header[0] !== 0x25 || // %
header[1] !== 0x50 || // P
header[2] !== 0x44 || // D
header[3] !== 0x46 || // F
header[4] !== 0x2d // -
) {
return { ok: false, errorMessage: 'Not a PDF file' };
}
Note the file.slice(0, 5) — this reads five bytes, not the whole file. A 200 MB scan does not need to be pulled into memory just to discover it is actually a ZIP archive.
Only after the magic bytes pass does the tool load the full file with pdf-lib to get the page count — and this is where the second real-world constraint shows up: encrypted PDFs. Password-protected files are common (bank statements, payroll exports), and pdf-lib refuses to load them by default. Rather than crashing, the validator catches the load error and checks whether the message mentions encryption:
try {
const doc = await PDFDocument.load(arrayBuffer);
return { ok: true, pageCount: doc.getPageCount() };
} catch (err) {
if (err instanceof Error && err.message.toLowerCase().includes('encrypt')) {
return { ok: false, errorMessage: 'This PDF is password-protected' };
}
throw err;
}
The file row then shows a clear inline error instead of a spinner that never resolves. It is a small thing, but "this PDF is password-protected" is a much better failure mode than a generic "something went wrong" — the user knows exactly what to do (remove the password first) rather than assuming the tool is broken.
One flat grid of pages, not a list of files
The core data model decision was to dissolve file boundaries early. Once a file validates, each of its pages becomes an independent PageItem carrying a reference to its source File and its zero-based index within that source. All pages from all uploaded files land in a single flat grid, numbered sequentially.
This makes merge and split the same operation. There is no "merge mode" and "split mode" — there is just a grid of pages you can reorder, select, delete, and export. Drag page 3 of document B between pages 1 and 2 of document A, delete the cover page you never wanted, click Download, and you have merged and split in one gesture. The export function does not care where pages came from:
export async function extractPagesToPdf(pages: PageItem[]): Promise<Uint8Array> {
const destDoc = await PDFDocument.create();
const docCache = new Map<File, PDFDocument>();
const getSourceDoc = async (file: File): Promise<PDFDocument> => {
if (!docCache.has(file)) {
const bytes = await file.arrayBuffer();
docCache.set(file, await PDFDocument.load(bytes));
}
return docCache.get(file)!;
};
for (const page of pages) {
const srcDoc = await getSourceDoc(page.sourceFile);
const [copiedPage] = await destDoc.copyPages(srcDoc, [page.sourcePageIndex]);
destDoc.addPage(copiedPage);
}
return destDoc.save();
}
Two details in that function came from getting it wrong first, at least on paper. The docCache map ensures each source file is parsed exactly once per export, no matter how many of its pages appear in the output — re-running PDFDocument.load() per page would re-parse the same multi-megabyte file over and over. And the page order is driven purely by the array the caller passes in, which is the grid order the user arranged. copyPages() brings along everything a page depends on — fonts, images, embedded resources — so the output is self-contained.
The same function serves both buttons: "Download PDF" passes every page in the grid (the merge case), and "Extract PDF" passes only the pages the user has click-selected (the split case). Splitting here is visual, not numeric — you do not type a range like 3-7; you click the thumbnails you want, they light up with a blue ring, and you extract exactly those pages in their current order. The resulting Uint8Array becomes a Blob, gets an object URL, and downloads through a synthesized anchor click. No bytes touch the network at any point.
Thumbnails without melting the tab
Rendering is where memory discipline actually matters. A naive implementation — render every page of every file at full resolution the moment it loads — will freeze the tab on a 150-page document. The thumbnail hook that wraps pdf.js applies four constraints, each one earned:
Lazy rendering with IntersectionObserver. No page renders until its card is within 200px of the viewport. Drop a 300-page PDF and only the first screenful of thumbnails costs anything; the rest render as you scroll toward them.
A concurrency-limited queue. Even visible pages do not all render at once. A queue drains at most three concurrent render tasks; each completed render pulls the next page off the queue. pdf.js rendering is CPU-heavy, and three parallel canvas rasterizations is roughly the point where the UI stays responsive.
Rendering to compressed JPEG blob URLs, not live canvases. Each thumbnail canvas exists only long enough to render, then is serialized with canvas.toBlob(..., 'image/jpeg', 0.85) and displayed as a plain <img> pointing at an object URL. A grid of 300 live canvases holds 300 uncompressed pixel buffers; a grid of 300 small JPEGs is a fraction of that. The canvas is sized at the physical-pixel resolution for the display's devicePixelRatio, so thumbnails stay sharp on HiDPI screens while the CSS size remains a fixed 150px.
Aggressive cleanup. Every resource has a matching teardown: object URLs are revoked when their page is removed from the grid and again on unmount, in-flight render tasks are cancelled (pdf.js throws a RenderingCancelledException that gets swallowed deliberately), and each file's pdf.js loadingTask.destroy() runs when the file leaves the list — that call tears down the worker-side document state, which URL.revokeObjectURL alone does not touch.
The hook also caches the pdf.js PDFDocumentProxy per File reference, mirroring the pdf-lib cache on the export side: a 50-page file is parsed once for rendering, not fifty times.
Drag-to-reorder that follows your cursor
Reordering uses dnd-kit's sortable primitives with rectSortingStrategy for the two-dimensional page grid (the file list above it uses verticalListSortingStrategy). Two configuration choices matter more than the library itself.
First, the pointer sensor has an activation distance of 5px. Page cards are also click-to-select targets, and without a threshold every selection click risks registering as a micro-drag. Requiring 5px of movement before a drag starts cleanly separates "click to select" from "drag to move." A keyboard sensor with sortable coordinates keeps reordering accessible without a pointer.
Second, the grid reorders on onDragOver, not just onDragEnd. The array actually mutates while you drag — via dnd-kit's arrayMove — so surrounding pages shuffle out of the way in real time and you can see exactly where the page will land. onDragEnd then only recalculates the sequential page numbers. Reordering only on drop technically works, but it feels like placing a card into a void; live reordering is the difference between a tool that feels native and one that feels like a form.
The SSR problem: a shell and a lazy client
The last constraint has nothing to do with PDFs and everything to do with how ToolRunner ships. Every route is prerendered to static HTML at build time for SEO — a Node script renders the React tree server-side and writes out complete HTML pages. And pdf.js does not survive that: importing pdfjs-dist in an environment without browser globals crashes the prerender outright.
The fix is a shell-and-client split, a pattern shared with the PDF Watermark tool. The route imports a thin shell component that contains everything SEO cares about — the meta tags, the JSON-LD, the breadcrumb, the tool description, even the privacy notice — none of which touches pdf.js. The interactive subtree loads through React.lazy:
// Lazy: usePdfThumbnails (in the client subtree) imports pdfjs-dist,
// which crashes SSR.
const PdfMergerSplitterClient = lazy(() => import('./PdfMergerSplitterClient'));
During prerendering, the lazy import never resolves — the build captures the shell with its Suspense fallback, which is all the crawler needs. In the browser, the client chunk loads immediately and hydrates into the working tool. One deliberate detail: the "all processing happens in your browser, no files are uploaded" notice lives in the shell, not the client, precisely so it appears in the prerendered HTML that search engines index. The privacy promise is part of the page's identity, not an afterthought rendered client-side.
The lazy-loading discipline goes one level deeper: even inside the client component, pdf-lib is dynamically imported only when it is needed — extractPagesToPdf is imported at download time, and pdf-lib itself at validation time. Someone who lands on the page and leaves never downloads a PDF parser at all.
What this architecture buys you
The honest summary is that building this client-side was more work than a fifty-line server endpoint calling a PDF CLI. The payoff is structural, not incremental. There is no server to breach, no retention policy to audit, no "files deleted after 1 hour" claim to take on faith — the architecture makes the privacy claim verifiable, since the network tab shows zero upload requests. It also scales for free: a hundred concurrent users cost nothing, because every user brings their own CPU.
The constraints were real — encrypted files need graceful detection, thumbnails need lazy rendering and hard concurrency limits, every object URL and render task needs a matching cleanup, and SSR-hostile libraries need a shell to hide behind. But none of them were dealbreakers, and the patterns transfer directly to ToolRunner's other document tools like the PDF Converter. If you are weighing a browser-only architecture for a file-processing tool, the two-library split — pdf-lib for manipulation, pdf.js for rendering — plus a lazy client boundary is a template I can now recommend from experience.
Sources
- pdf-lib documentation — API reference for
PDFDocument.load,copyPages, andsave - Mozilla pdf.js — the rendering engine behind the thumbnails
- MDN: Blob.arrayBuffer() — reading File bytes into an ArrayBuffer client-side