<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Fileora</title>
    <description>The latest articles on DEV Community by Fileora (@fileoratool).</description>
    <link>https://dev.to/fileoratool</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4164254%2Fa49cbbdf-3777-45a8-be9e-bd68bb59ef49.png</url>
      <title>DEV Community: Fileora</title>
      <link>https://dev.to/fileoratool</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fileoratool"/>
    <language>en</language>
    <item>
      <title>150 file tools, zero uploads: running ffmpeg, OCR and PDF editing in the browser</title>
      <dc:creator>Fileora</dc:creator>
      <pubDate>Sat, 10 Oct 2026 13:41:04 +0000</pubDate>
      <link>https://dev.to/fileoratool/150-file-tools-zero-uploads-running-ffmpeg-ocr-and-pdf-editing-in-the-browser-4gog</link>
      <guid>https://dev.to/fileoratool/150-file-tools-zero-uploads-running-ffmpeg-ocr-and-pdf-editing-in-the-browser-4gog</guid>
      <description>&lt;p&gt;Most "free online PDF tool" sites work the same way: you upload a file, a server processes it, you download the result. That is fine for a meme. It is less fine for a passport scan, a signed contract or a bank statement.&lt;/p&gt;

&lt;p&gt;We built &lt;a href="https://fileora.tools" rel="noopener noreferrer"&gt;Fileora&lt;/a&gt; the other way round. It has 150 tools for PDFs, images, video, audio and text, and &lt;strong&gt;no backend at all&lt;/strong&gt;. Every file is opened, processed and saved by the browser on the user's own device. There is no upload endpoint to secure, because there is no upload.&lt;/p&gt;

&lt;p&gt;This post is about what that takes in practice: the parts that were easy, the parts that were not, and the limits you should know about before trying the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; (App Router), statically generated, hosted on a free tier&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pdf-lib&lt;/strong&gt; to edit PDF structure, &lt;strong&gt;pdf.js&lt;/strong&gt; to render pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ffmpeg.wasm&lt;/strong&gt; for video and audio&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tesseract.js&lt;/strong&gt; for OCR&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;qpdf&lt;/strong&gt; compiled to WebAssembly for PDF encryption&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Canvas API&lt;/strong&gt; for everything image-related&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fflate&lt;/strong&gt; for ZIP downloads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is exotic. The work is in how you load it and how you keep a phone from running out of memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: nothing heavy loads until someone clicks
&lt;/h2&gt;

&lt;p&gt;A tool page has to be a normal, fast web page. The ffmpeg core alone is about 31 MB. If that loaded with the page, nobody would stay long enough to use it.&lt;/p&gt;

&lt;p&gt;So each tool is its own chunk, and every heavy library is imported inside the handler that needs it, never at the top of a file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FFmpeg&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadFFmpeg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onProgress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FFmpeg&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;loading&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FFmpeg&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@ffmpeg/ffmpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ffmpeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FFmpeg&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wasmURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchWithProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/vendor/ffmpeg/ffmpeg-core.wasm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ffmpeg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;classWorkerURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;coreURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wasmURL&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revokeObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wasmURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ffmpeg&lt;/span&gt;
  &lt;span class="p"&gt;})().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;loading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="c1"&gt;// allow a retry after a failed download&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;loading&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Show the download.&lt;/strong&gt; A 31 MB fetch with a spinner looks like a frozen page. We read the response body with a stream reader, count the bytes, and show a real progress bar. After the first run the core comes from the browser cache.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reset the promise on failure.&lt;/strong&gt; If you cache a rejected promise, one dropped connection breaks the tool until the page is reloaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: single-threaded ffmpeg, on purpose
&lt;/h2&gt;

&lt;p&gt;ffmpeg.wasm has a multi-threaded core that is noticeably faster. It needs &lt;code&gt;SharedArrayBuffer&lt;/code&gt;, and browsers only expose that on cross-origin isolated pages, which means sending &lt;code&gt;Cross-Origin-Opener-Policy&lt;/code&gt; and &lt;code&gt;Cross-Origin-Embedder-Policy&lt;/code&gt; headers.&lt;/p&gt;

&lt;p&gt;Those headers also block any third-party resource that does not opt in. For a site that plans to pay its bills with ads, that is a non-starter. We use the single-threaded core and accept slower encodes.&lt;/p&gt;

&lt;p&gt;One more ffmpeg lesson: the &lt;code&gt;progress&lt;/code&gt; value it reports is unreliable for many inputs. We parse the &lt;code&gt;Duration:&lt;/code&gt; line from the log output once, then divide the &lt;code&gt;time&lt;/code&gt; field (which is in microseconds) by it. That gives a progress bar that actually reaches 100%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: self-host every runtime file
&lt;/h2&gt;

&lt;p&gt;Workers, WebAssembly binaries, OCR language data and fonts are all served from our own domain. A small script copies them out of &lt;code&gt;node_modules&lt;/code&gt; into &lt;code&gt;public/vendor&lt;/code&gt; before each build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pkgDir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdfjs-dist&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;build&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdf.worker.min.mjs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdf.worker.min.mjs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;...[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ffmpeg-core.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ffmpeg-core.wasm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pkgDir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@ffmpeg/core&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dist&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;esm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;`ffmpeg/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="c1"&gt;// tesseract worker, cores and language data, qpdf, fonts…&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does two things. The worker file always matches the installed library version, which removes a whole class of "works locally, breaks in production" bugs. And the privacy claim stays honest: processing a file does not trigger a request to someone else's CDN.&lt;/p&gt;

&lt;h2&gt;
  
  
  Images: hitting an exact file size with canvas
&lt;/h2&gt;

&lt;p&gt;One of the most common jobs is "make this photo under 50 KB", because application forms love that rule. Canvas gives you &lt;code&gt;toBlob(callback, type, quality)&lt;/code&gt; but no way to ask for a size. So we search for it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bestUnderTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;targetBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;floor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;canvasToBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MIN_QUALITY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;targetBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;MIN_QUALITY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_QUALITY&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;canvasToBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;targetBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven steps of binary search over quality gets very close to the best result that fits. If even the lowest quality is too big, we step down a ladder of scales (85%, 70%, 55%…) and search again at each size. If the original already fits, we return it untouched rather than making it worse.&lt;/p&gt;

&lt;p&gt;Three canvas traps we hit along the way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Silent PNG fallback.&lt;/strong&gt; Ask an older browser for WebP and it may hand you a PNG without complaining. Check &lt;code&gt;blob.type&lt;/code&gt; against what you asked for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Black backgrounds.&lt;/strong&gt; JPEG has no alpha channel. Fill the canvas with white before drawing, or transparent logos come out on black.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory on mobile Safari.&lt;/strong&gt; Setting &lt;code&gt;canvas.width = 0&lt;/code&gt; when you are done releases the bitmap memory promptly. Without it, a batch of large photos can kill the tab.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  PDFs: three libraries, three jobs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;pdf-lib&lt;/strong&gt; edits structure. Merging copies pages from one document into another as they are, so text stays selectable and nothing is re-compressed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pdf.js&lt;/strong&gt; renders. Anything that needs to &lt;em&gt;show&lt;/em&gt; a page (signing, cropping, converting to an image) goes through it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compression&lt;/strong&gt; uses both, in two modes. The gentle mode walks the PDF's objects, finds embedded images in colour spaces it can safely convert, and re-encodes only those, leaving text and vectors alone. The aggressive mode renders each page to an image, which is what helps with scans. In both modes, if the result is not smaller, the user keeps the original.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Encryption&lt;/strong&gt; was the surprise. pdf-lib cannot write an encrypted PDF. We compiled nothing ourselves: qpdf already exists as a WebAssembly build. Each lock or unlock job runs in a fresh worker that is terminated afterwards, so its memory is returned immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./security.worker.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;module&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="c1"&gt;// transfer the ArrayBuffer, don't copy it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Transferring the buffer instead of copying it matters when the file is 80 MB and the device is a phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  OCR: tell people what they are about to download
&lt;/h2&gt;

&lt;p&gt;tesseract.js works well in a browser, but it needs an engine (about 4 MB) plus a data file per language (1 to 3 MB each). We ship five languages and show the first-run download size before the user starts, so nobody on mobile data is surprised.&lt;/p&gt;

&lt;p&gt;The biggest quality win was not a model setting. Tesseract reads best when letters are around 30 pixels tall, and most screenshots are far below that. We upscale small images by up to 3× before recognition and cap very large photos at 5000 pixels on the longest side. Small cropped screenshots read much better this way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where browser-only hurts
&lt;/h2&gt;

&lt;p&gt;It would be dishonest to stop at the wins.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Everything lives in memory.&lt;/strong&gt; There is no streaming to disk. We warn above 100 MB, and a long video on an old phone can still fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-threaded video is slow.&lt;/strong&gt; Trimming is quick; re-encoding a long clip is a wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First use costs a download.&lt;/strong&gt; 31 MB for ffmpeg is fine on Wi-Fi and unfriendly on a metered connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browsers differ.&lt;/strong&gt; Canvas size limits, WebP encoding and HEIC decoding all vary. For HEIC we fall back to a libheif build only when the browser cannot decode the format itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You cannot fix a user's file for them.&lt;/strong&gt; With no server and no logs, a bug report is "it didn't work". Error messages have to be specific, because they are the only diagnostics you get.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;For this kind of product, yes. There is no processing bill that grows with usage, no file storage to secure or to delete, and the privacy promise is something a user can verify in the network tab instead of taking on trust.&lt;/p&gt;

&lt;p&gt;The cost is that the hard problems move to the client: memory, download size and browser differences. If your users have small files and modern browsers, the trade is easy. If they have 2 GB videos, it is not.&lt;/p&gt;

&lt;p&gt;You can try the tools at &lt;a href="https://fileora.tools" rel="noopener noreferrer"&gt;fileora.tools&lt;/a&gt;, and the full list is on GitHub: &lt;a href="https://github.com/fileoratool/free-browser-tools" rel="noopener noreferrer"&gt;free-browser-tools&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you have built something similar, we would like to hear how you handled large files. That is the part we are least happy with.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>webassembly</category>
      <category>privacy</category>
    </item>
    <item>
      <title>We built 149 file tools that run entirely in the browser. Here is what went wrong</title>
      <dc:creator>Fileora</dc:creator>
      <pubDate>Mon, 05 Oct 2026 15:48:57 +0000</pubDate>
      <link>https://dev.to/fileoratool/we-built-149-file-tools-that-run-entirely-in-the-browser-here-is-what-went-wrong-3e75</link>
      <guid>https://dev.to/fileoratool/we-built-149-file-tools-that-run-entirely-in-the-browser-here-is-what-went-wrong-3e75</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo3s2bv0k55rbx26dhyyd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo3s2bv0k55rbx26dhyyd.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
Last week we launched Fileora (&lt;a href="https://fileora.tools" rel="noopener noreferrer"&gt;fileora.tools&lt;/a&gt;): 149 small tools for PDFs, images, video, audio, text and unit conversion. There is no backend, no database and no login. Every file is processed inside the browser tab and never uploaded.&lt;/p&gt;

&lt;p&gt;That constraint was the whole point. It keeps user files private and it keeps hosting costs close to zero. It also caused most of the problems below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The stack:&lt;/strong&gt; Next.js 16 (App Router), strict TypeScript, Tailwind v4, shadcn/ui. The actual work is done by pdf-lib and pdfjs-dist for PDFs, ffmpeg.wasm for video and audio, tesseract.js for OCR, fflate for ZIP files, and plain Canvas helpers for images.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Multi-threaded FFmpeg and ad scripts cannot share a page
&lt;/h2&gt;

&lt;p&gt;ffmpeg.wasm comes in two flavours. The multi-threaded core is much faster, but it needs &lt;code&gt;SharedArrayBuffer&lt;/code&gt;, and browsers only allow that on pages that are cross-origin isolated. That means sending COOP and COEP headers.&lt;/p&gt;

&lt;p&gt;The catch: with COEP enabled, the page refuses any cross-origin resource that has not opted in. Third-party scripts and iframes, including ad networks, stop loading. The site is meant to be ad-supported, so that was not an option.&lt;/p&gt;

&lt;p&gt;We went with the single-threaded core. It is self-hosted and lazy loaded, so the large WebAssembly file is only downloaded when someone opens a video or audio tool. Slower encodes, but the rest of the site stays a normal web page.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. One registry instead of 149 pages
&lt;/h2&gt;

&lt;p&gt;Every tool is an entry in a single registry file and lives at &lt;code&gt;/tools/[slug]&lt;/code&gt;. Many tools are not new code at all. "Compress image to 50 KB" is the image compressor with a preset. A variant is a component plus a preset, so adding one is mostly a content job.&lt;/p&gt;

&lt;p&gt;Each entry also has a status. A &lt;code&gt;draft&lt;/code&gt; tool builds and works locally but returns a 404 in production. That let us commit half-finished tools without worrying about them leaking out, and publish them in batches with a small script.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A translation ships only when it is complete
&lt;/h2&gt;

&lt;p&gt;The site is in English, Urdu (right to left), Spanish, Portuguese and Indonesian, using a small i18n system of our own. The rule is simple: a tool appears in a language only if its long-form content and its UI strings both exist in that language.&lt;/p&gt;

&lt;p&gt;So the counts are uneven on purpose. English has 149 tools, Urdu 87, Spanish 85, Portuguese 85, Indonesian 80. No half-translated pages, and hreflang tags only point at pages that really exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A static site can still eat your bandwidth
&lt;/h2&gt;

&lt;p&gt;In the first three days the site used about 1 GB of a 10 GB monthly transfer allowance, before any real traffic arrived. Things we changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;prefetch={false}&lt;/code&gt; on link-heavy lists. A category page with dozens of tool cards was prefetching every one of them.&lt;/li&gt;
&lt;li&gt;Several commits per push instead of one push per commit. Each deploy has a cost.&lt;/li&gt;
&lt;li&gt;After a deploy we check 5 to 10 sample URLs, not the whole sitemap.&lt;/li&gt;
&lt;li&gt;OG image routes now list locale and slug in &lt;code&gt;generateStaticParams&lt;/code&gt;, so they are built once instead of on request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Still on the list: every page ships the full registry text (about 22 KB) for search. Lazy loading that should cut transfer by a fifth or more.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Search data disagreed with our roadmap
&lt;/h2&gt;

&lt;p&gt;We assumed the PDF tools would be the main draw. In the first week of Search Console data, the biggest group of queries was unit converters: kg to lb, cm to feet and inches, Celsius to Fahrenheit. And the leading language was Spanish, not English.&lt;/p&gt;

&lt;p&gt;People also search for exact numbers. "23 kg to lbs" showed up again and again, which makes sense once you remember it is the standard airline baggage limit. It is tempting to generate a page for every number. We did not, because those are thin pages. The common values go into the converter page itself.&lt;/p&gt;

&lt;p&gt;To be honest about scale: that week was roughly 1,000 impressions, 7 clicks and an average position of 38. Nobody is finding the site through search yet. But the data already told us to build converters in Spanish and Portuguese before polishing another English PDF page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would like to know
&lt;/h2&gt;

&lt;p&gt;If you have run multi-threaded ffmpeg.wasm on a page that also loads third-party scripts, we would like to hear how you handled the isolation headers. And if you spot a tool that breaks on your files, tell us. That is the most useful feedback we can get right now.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>tools</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
