
I'm the developer of MakeQuickWork, a small site of free browser tools. This post is about how its Merge PDF tool works under the hood.
Most "merge PDF online" sites follow the same pattern: you upload your files, a server stitches them together, and you download the result. That works, but the files you merge are often the ones you least want to hand to a stranger's server: a signed contract plus its annex, a passport scan for a visa application, a pile of receipts for an expense report.
Merging PDFs doesn't actually need a server. Everything can happen in the tab. Here's how I built Merge PDF so that files never leave the device, and how I check that it stays that way.
The shape of the tool
The UI is deliberately small: pick two or more PDFs, see a first-page preview and page count for each, reorder them with arrow buttons, click Merge PDFs, download one file. No sign-up, no watermark, no daily limit, no file-count limit.
The site is a static Astro build on Cloudflare Pages. There's no backend for this tool at all, which makes the privacy claim simple: there is nowhere to upload to.
Merging losslessly with copyPages
The core is a few lines of pdf-lib (specifically a maintained fork, @pdfme/pdf-lib):
export async function mergePdfs(files: File[], onProgress: (done: number, total: number) => void) {
const out = await PDFDocument.create();
for (let i = 0; i < files.length; i++) {
const src = await loadEditable(await readBytes(files[i]), files[i].name);
// copyPages pulls each page object and everything it references
// (content streams, fonts, images) into the new document.
const pages = await out.copyPages(src, src.getPageIndices());
pages.forEach((p) => out.addPage(p));
onProgress(i + 1, files.length);
}
return toBlob(out); // doc.save({ useObjectStreams: true }) -> Blob
}
copyPages doesn't rasterize or re-encode anything. Text stays selectable, fonts stay embedded, images keep their original bytes, and vector graphics stay vector. Each page also keeps its own MediaBox, so an A4 report followed by a US Letter invoice comes out exactly as A4 then Letter.
loadEditable is a thin wrapper that turns pdf-lib's errors into readable messages. In particular, encrypted files are rejected with "remove the password first", because password-protected PDFs aren't supported yet.
What doesn't survive
It's better to be upfront about this:
-
Bookmarks (the outline) are not kept. The outline lives in the document catalog, not in the pages, so
copyPagesdoesn't bring it along. - Web links usually work, because they're annotations on the page. Links that jump to another place inside a file may stop working, since their destinations point into the old document.
- Complex form fields may not work, for similar reasons: AcroForm data is document-level.
PDF.js only for previews
Rendering is a different job from rewriting. pdf-lib can restructure a PDF but can't draw it, so the first-page thumbnails come from Mozilla's PDF.js:
export async function preview(file: File) {
const doc = await openPdf(await readBytes(file));
try {
return { pages: doc.numPages, canvas: await renderThumbnail(doc, 1, 200) };
} finally {
closePdf(doc); // free the worker-side document right away
}
}
PDF.js never touches the output. The merged file is produced only by pdf-lib, so what you download is the original pages, not a re-rendered copy.
Lazy-loading about 1 MB of PDF code
pdf-lib alone is roughly 1 MB of JavaScript, and PDF.js plus its worker is more. Shipping that on page load would make the landing page slow for people who just want to read what the tool does.
So the page ships only the UI. The merge module is pulled in with a dynamic import() after the user picks files:
input.addEventListener("change", async () => {
const { preview, mergePdfs } = await import("./merge"); // fetched on first use
// ...
});
Those requests are plain same-origin GETs for static assets. That distinction matters for the test below.
Previews one at a time, only when visible
Someone will drop a dozen large PDFs at once. Rendering every thumbnail in parallel would spike memory and freeze the tab. Thumbnails go through a tiny queue driven by IntersectionObserver:
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
observer.unobserve(entry.target);
const job = jobs.get(entry.target);
jobs.delete(entry.target);
if (job) queue = queue.then(job).catch(() => {}); // strictly one at a time
}
}, { rootMargin: "400px 0px" });
A card renders when it gets within 400px of the viewport, and jobs run serially. If a card is removed before rendering, its job is cancelled. The same helper handles page thumbnails in the other PDF tools, so a 500-page document doesn't freeze the tab.
The merge itself still needs all source files in memory at once, so very large merges are limited by the device's memory. There's no artificial cap, but a phone will give up before a desktop does.
A test that fails if anything is uploaded
"Files never leave your device" is a promise, and promises regress. One careless analytics snippet or a well-meaning "save to cloud" feature and it's broken. So there's a Playwright test for every local tool:
function watch(page: Page) {
const bad: string[] = [];
page.on("request", (r) => {
const url = new URL(r.url());
if (GOOGLE.test(url.hostname)) return; // GA4, checked separately below
const local = url.origin === "http://localhost:4321"
|| url.protocol === "blob:" || url.protocol === "data:";
if (r.method() !== "GET" || !local) bad.push(`${r.method()} ${r.url()}`);
});
return bad;
}
The merge case loads two fixture PDFs, clicks Merge PDFs, waits for the download, and asserts bad is empty. Any POST or PUT, or any request to another origin while the tool is processing, fails CI, with one exception: Google Analytics (the site uses GA4 for anonymous visit counts). For that, the test reads the analytics events instead and fails if any of them contains the file name. Lazy-loaded chunks pass because they're same-origin GETs.
You don't have to trust the test either. Open DevTools, go to the Network tab, merge a couple of files, and watch: you'll see the JS chunks load and, if you allowed analytics, a few small Google Analytics requests, but nothing that carries your files. The how it works page walks through it. You do need a connection to open the page and fetch the code; I'm not claiming it works offline.
Wrapping up
The whole tool is a static page, a 20-line merge function, a preview helper, and a test that guards the one promise that matters. It's free and not open source. The same approach powers the sibling tools: Split PDF, PDF to JPG, JPG to PDF, and a File Viewer.
Try it here: https://makequickwork.com/pdf/merge-pdf/
I'd like to hear from anyone who has dealt with preserving outlines or internal link destinations across a merge in pdf-lib. That's the next limitation I'd like to remove.



Top comments (0)