Running PDF, OCR and AI tools entirely in the browser: what worked and what broke A developer built Utilkits, a Next.js 16 site of 99 everyday tools whose PDF, image and on-device AI features run entirely client-side, using pdf-lib, pdf.js, qpdf compiled to WebAssembly, Tesseract.js in a web worker, and Transformers.js with onnxruntime-web. The writeup details two production failures: a self-hosted transformers.web.min.js that imported onnxruntime-web/webgpu by bare module name and fetched its .wasm engine from a CDN blocked by the content security policy, fixed by loading transformers.min.js, copying onnxruntime files into /transformers/ and setting env.backends.onnx.wasm.wasmPaths in place rather than replacing the settings object; and a background remover that broke because Transformers.js 4 lacks U2NetImageProcessor, fixed by preparing inputs in the worker from preprocessor_config.json and reading the model's real output. Each tool page carries a privacy badge that blocks fetch, XMLHttpRequest and sendBeacon and sets Google Analytics' ga-disable flag. Most "free online PDF tools" upload your file to a server, process it there, and delete it an hour or two later. I wanted the opposite for Utilkits https://utilkits.com : 99 everyday tools, where the PDF and image tools keep the file on your device. Here is how the browser does the heavy lifting, where it falls short, and the two bugs that taught me the most. It's a Next.js 16 app, and the PDF, image and on-device AI tools run client-side: | Job | Library | |---|---| | Merge, split, rotate, watermark, page numbers | pdf-lib | | Render pages, extract text, PDF to JPG/PNG | pdf.js pdfjs-dist | | Password-protect and unlock AES-256 | qpdf compiled to WebAssembly qpdf-wasm | | OCR | Tesseract.js, in a web worker | | PDF to Word / Excel / PowerPoint | docx, SheetJS xlsx , PptxGenJS, rebuilt from the extracted text | | Image compression and conversion | browser-image-compression and canvas | | On-device AI | Transformers.js with onnxruntime-web WebAssembly | The rule I hold myself to: if a tool can't run locally, the page says so. The redirect checker has to follow redirects from a server, so the how it works https://utilkits.com/how-it-works page lists it as an exception. BritishWerewolf/U-2-Netp , about 4.6 MB , run in a web worker so the page never freezes. onnx-community/whisper-tiny.en . Xenova/distilbart-cnn-6-6 . all-MiniLM-L6-v2 , about 24 MB finds the passages closest to the question. A DistilBERT trained on SQuAD about 66 MB then quotes the answer, with its page number. It quotes rather than generates, so it can't invent a citation. If it isn't confident, it says so and shows the closest passages. The models download once from Hugging Face and the browser caches them. The files themselves never leave the device. The AI tools worked locally and failed in production on first use. The self-hosted transformers.web.min.js imported onnxruntime-web/webgpu by bare name, and no browser can resolve a bare module name without an import map. On top of that, the library's default is to fetch the onnxruntime .wasm engine from jsDelivr, which my content security policy blocks. The fix: load transformers.min.js instead, copy the onnxruntime files from node modules into /transformers/ before every build, and point env.backends.onnx.wasm.wasmPaths at that folder. That fix had its own trap. Set wasmPaths on the existing settings object; don't replace the object. The runtime keeps a reference to the original, so assigning a new object does nothing and the engine still comes from the CDN. The background remover also "worked" in development and never in production. Transformers.js 4 has no U2NetImageProcessor , and my code read an output name the model doesn't have. Now the worker prepares the input itself: preprocessor config.json . input.1 , and read its real saliency map, output 1959 . The preparation is a set of pure functions, so the same code runs in the worker and in Node unit tests. "Your file stays on your device" is easy to say, so every tool page has a privacy badge : fetch , XMLHttpRequest and navigator.sendBeacon to block the page's own requests, and sets Google Analytics' official ga-disable flag. The badge also suggests the real test: load the page, turn off Wi-Fi, run the tool. If it still works, your file had nowhere to go. Everything is at utilkits.com https://utilkits.com . The single-file tools are free, with no account. If you catch a tool sending your file anywhere, tell me in the comments.