<?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: Lakhyajit Changmai</title>
    <description>The latest articles on DEV Community by Lakhyajit Changmai (@lakhyajit).</description>
    <link>https://dev.to/lakhyajit</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%2F4187951%2Fe91c517e-0815-4d26-961f-80ca98c97721.jpg</url>
      <title>DEV Community: Lakhyajit Changmai</title>
      <link>https://dev.to/lakhyajit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lakhyajit"/>
    <language>en</language>
    <item>
      <title>Local file processing needs a precise boundary</title>
      <dc:creator>Lakhyajit Changmai</dc:creator>
      <pubDate>Mon, 02 Nov 2026 07:52:46 +0000</pubDate>
      <link>https://dev.to/lakhyajit/local-file-processing-needs-a-precise-boundary-42np</link>
      <guid>https://dev.to/lakhyajit/local-file-processing-needs-a-precise-boundary-42np</guid>
      <description>&lt;p&gt;`“The file never leaves your browser” sounds simple, but it can describe only part of the data flow. A web app still downloads its HTML, JavaScript, and other assets. The useful question is what happens to the bytes a person selects.&lt;/p&gt;

&lt;p&gt;For ZANCTA’s supported PDF and image workflows, processing happens in the current browser tab. That means the page should explain this boundary plainly: files are processed locally while the website itself still loads normally.&lt;/p&gt;

&lt;p&gt;This distinction also keeps the claim honest. Local processing does not mean every operation is available offline, and it does not remove the need to explain browser permissions, file limits, or the behavior of each tool.&lt;/p&gt;

&lt;p&gt;When you build a file utility, make the data path visible: which inputs are handled locally, which operations have limits, and what is transmitted to the service. Clear language helps users decide whether a tool fits their document and their threat model.`&lt;/p&gt;

</description>
      <category>privacy</category>
    </item>
    <item>
      <title>crossOriginIsolated is a tradeoff, not a checkbox - why my in-browser ML model runs single-threaded</title>
      <dc:creator>Lakhyajit Changmai</dc:creator>
      <pubDate>Fri, 30 Oct 2026 15:05:38 +0000</pubDate>
      <link>https://dev.to/lakhyajit/crossoriginisolated-is-a-tradeoff-not-a-checkbox-why-my-in-browser-ml-model-runs-single-threaded-13d</link>
      <guid>https://dev.to/lakhyajit/crossoriginisolated-is-a-tradeoff-not-a-checkbox-why-my-in-browser-ml-model-runs-single-threaded-13d</guid>
      <description>&lt;h1&gt;
  
  
  crossOriginIsolated is a tradeoff, not a checkbox — why my in-browser ML model runs single-threaded
&lt;/h1&gt;

&lt;p&gt;If you run an ONNX model in the browser with &lt;code&gt;onnxruntime-web&lt;/code&gt;, the multithreaded WASM build wants &lt;code&gt;SharedArrayBuffer&lt;/code&gt;. And &lt;code&gt;SharedArrayBuffer&lt;/code&gt; is only available when the page is &lt;strong&gt;cross-origin isolated&lt;/strong&gt;, which means you've shipped both a COOP and a COEP header.&lt;/p&gt;

&lt;p&gt;The tempting advice is "just enable cross-origin isolation." I want to show why we deliberately did &lt;em&gt;not&lt;/em&gt;, in a real production app, and what the honest tradeoff looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we actually do
&lt;/h2&gt;

&lt;p&gt;Our background-removal tool self-hosts a small model (u2netp) and runs it through &lt;code&gt;onnxruntime-web&lt;/code&gt;'s wasm bundle. The relevant part of our engine, verbatim:&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;ort&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;onnxruntime-web/wasm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;OrtModule&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ortConfigured&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wasm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wasmPaths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BG_WASM_BASE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// This site is not cross-origin isolated, so SharedArrayBuffer threading is&lt;/span&gt;
  &lt;span class="c1"&gt;// unavailable; pin to a single thread to avoid a broken threaded init.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crossOriginIsolated&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crossOriginIsolated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wasm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;numThreads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;ort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wasm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;proxy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ortConfigured&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We detect isolation with &lt;code&gt;globalThis.crossOriginIsolated&lt;/code&gt; and, when it's false, pin &lt;code&gt;numThreads = 1&lt;/code&gt; instead of letting a threaded init half-break. We also show a "compatibility mode" message rather than an error. The model just runs single-threaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just turn on COOP/COEP?
&lt;/h2&gt;

&lt;p&gt;Because COEP changes the rules for &lt;strong&gt;every&lt;/strong&gt; cross-origin subresource on the page. With &lt;code&gt;Cross-Origin-Embedder-Policy: require-corp&lt;/code&gt;, anything you load from another origin has to opt in with a &lt;code&gt;Cross-Origin-Resource-Policy&lt;/code&gt; header (or CORS). If it doesn't, the browser blocks it — silently.&lt;/p&gt;

&lt;p&gt;Our own decision log is blunt about this. The note next to the threading section reads:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Threading requires &lt;code&gt;Cross-Origin-Isolated&lt;/code&gt; for &lt;code&gt;wasm_threads&lt;/code&gt;. If not isolated, gracefully fall back to single-thread WASM (feature detect, show "compatibility mode"). &lt;strong&gt;Do not require &lt;code&gt;COOP/COEP&lt;/code&gt; headers in MVP — they break OAuth/ads.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;require-corp&lt;/code&gt; would have broken our OAuth sign-in popup and the third-party scripts we legitimately load, all to win threading on one optional, occasionally-used tool. For a privacy-first tool where the &lt;em&gt;headline&lt;/em&gt; is "your file never leaves the tab," making the whole page's resource loading more fragile to buy some inference speed on the heaviest feature was not a good trade. The small model + single-thread fallback was the honest engineering call.&lt;/p&gt;

&lt;p&gt;I want to be equally honest about what I &lt;em&gt;didn't&lt;/em&gt; do: I have no measured, per-device speed numbers for single- vs multi-thread on our exact model. The decision was structural (does isolation break the things I need?), not a benchmark win I can quote.&lt;/p&gt;

&lt;h2&gt;
  
  
  The third option I didn't know about
&lt;/h2&gt;

&lt;p&gt;This came up in a thread with &lt;a class="mentioned-user" href="https://dev.to/szp2005"&gt;@szp2005&lt;/a&gt;, who runs a heavier matting model in the browser. When I described our not-isolated, single-threaded setup, they pointed out that we weren't limited to the binary "isolation vs. broken analytics" I'd assumed. Their words:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We do isolate, using &lt;code&gt;COEP: credentialless&lt;/code&gt; instead of &lt;code&gt;require-corp&lt;/code&gt;. Safari ignores it and stays single-threaded.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That reframed the whole choice for me. &lt;code&gt;credentialless&lt;/code&gt; lets cross-origin subresources load &lt;strong&gt;without credentials and without each resource sending a CORP header&lt;/strong&gt; — so you get the isolation (and the threaded WASM) without forcing every third-party embed to opt in first. The exact embed-blocking that made &lt;code&gt;require-corp&lt;/code&gt; unusable for us largely stops applying.&lt;/p&gt;

&lt;p&gt;And the Safari caveat is the part that actually matters. If a browser ignores &lt;code&gt;credentialless&lt;/code&gt;, then cross-origin isolation is a &lt;strong&gt;progressive enhancement, not a guarantee.&lt;/strong&gt; You cannot design the happy path assuming isolation is on, because there is always an engine where it silently isn't. Which is exactly why our &lt;code&gt;numThreads = 1&lt;/code&gt; fallback and their "timeout retry had to start a new Worker" both have to exist regardless of which COEP mode you ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Treat &lt;code&gt;crossOriginIsolated&lt;/code&gt; as a capability you feature-detect, never a precondition you assume. Ship the single-threaded path first.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;require-corp&lt;/code&gt; is a page-wide contract with every third-party you load; that's a big blast radius for a speed win on one feature.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;COEP: credentialless&lt;/code&gt; is worth testing specifically because its failure mode (blocking non-CORP embeds) is the one that usually disqualifies COEP — but plan for the engines that ignore it.&lt;/li&gt;
&lt;li&gt;The right question isn't "how do I get multithreaded WASM," it's "what does turning on isolation break, and is the thing I gain worth it."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've wrestled with &lt;code&gt;credentialless&lt;/code&gt; vs &lt;code&gt;require-corp&lt;/code&gt; in a real app, I'd genuinely like to hear where it held and where it didn't.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: drafted with AI assistance from my own source code and the linked conversation; every code excerpt and quoted claim was checked against the actual repository before publishing. I wrote and verified the content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Five rich-text editors, five ways to inject text, and the layer that actually matters</title>
      <dc:creator>Lakhyajit Changmai</dc:creator>
      <pubDate>Tue, 27 Oct 2026 17:36:11 +0000</pubDate>
      <link>https://dev.to/lakhyajit/five-rich-text-editors-five-ways-to-inject-text-and-the-layer-that-actually-matters-30fp</link>
      <guid>https://dev.to/lakhyajit/five-rich-text-editors-five-ways-to-inject-text-and-the-layer-that-actually-matters-30fp</guid>
      <description>&lt;p&gt;The DOM is not the source of truth. That sounds obvious, and it is the thing that breaks almost every browser agent that edits text on a page. The field fills, the save endpoint answers 200, and the value stored is empty. I hit this five times in one day on five different editors, and the fix was always the same idea: find the layer the submit handler reads from, and write to that one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three layers, only one of which is authoritative
&lt;/h2&gt;

&lt;p&gt;Most text inputs on the modern web are not the node you can see. They are a stack:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the DOM - what is rendered&lt;/li&gt;
&lt;li&gt;the editor document - ProseMirror state, Quill's internal model, Draft.js &lt;code&gt;ContentState&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the application store - the React or Redux state that builds the request body&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;element.value = ...&lt;/code&gt;, &lt;code&gt;innerText = ...&lt;/code&gt; and &lt;code&gt;document.execCommand(...)&lt;/code&gt; touch layer 1, or reach layer 2 by accident. The submit handler serialises layer 3. When nothing propagates upward, you get a clean, successful no-op.&lt;/p&gt;

&lt;p&gt;What follows is what worked on each editor. All of it was found the slow way.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. ProseMirror: &lt;code&gt;beforeinput&lt;/code&gt;, not &lt;code&gt;execCommand&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;ProseMirror listens for the &lt;code&gt;beforeinput&lt;/code&gt; event and mutates its document in response to it. It does not read what you write into the DOM.&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;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.ProseMirror&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&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;ev&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;InputEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;beforeinput&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="na"&gt;inputType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;insertText&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;bubbles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cancelable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;composed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;document.execCommand('insertText')&lt;/code&gt; and driver-level typing both produced the same result here: the text appeared in the field, Save returned success, and the stored document was one empty paragraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Tiptap: a real paste payload
&lt;/h2&gt;

&lt;p&gt;Tiptap is ProseMirror underneath, but its paste handler is the part you can reach. Dispatch a synthetic clipboard event carrying a real &lt;code&gt;DataTransfer&lt;/code&gt;:&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;dt&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;DataTransfer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/plain&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&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;consumed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;editorEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&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;ClipboardEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paste&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="na"&gt;clipboardData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bubbles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cancelable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check &lt;code&gt;consumed&lt;/code&gt;. &lt;code&gt;dispatchEvent&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; only when a handler called &lt;code&gt;preventDefault()&lt;/code&gt;, so that boolean is your proof the editor took the payload rather than the browser shrugging at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Quill: &lt;code&gt;execCommand&lt;/code&gt; works, and it multiplies newlines
&lt;/h2&gt;

&lt;p&gt;Quill ignored the synthetic paste entirely but accepted this:&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="nx"&gt;editorEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;insertText&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One quirk cost me a visible edit: Quill expands paragraph breaks, so two newlines in came back as four blank lines out. Author with single newlines and re-read the result before publishing.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Draft.js: only trusted input, and &lt;code&gt;innerText&lt;/code&gt; lies
&lt;/h2&gt;

&lt;p&gt;Draft.js (X's composer) keeps its text in its own render nodes:&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;stateText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;composer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;span[data-text="true"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use that as the oracle, not &lt;code&gt;innerText&lt;/code&gt;. After an &lt;code&gt;execCommand&lt;/code&gt; write, Draft.js leaves a sibling text node that React never reconciles, so &lt;code&gt;innerText&lt;/code&gt; double-counts and the character counter reports a number that is not true of the state. I published a truncated post believing that counter.&lt;/p&gt;

&lt;p&gt;Synthetically, nothing works on Draft.js: &lt;code&gt;beforeinput&lt;/code&gt; is ignored, and a synthetic paste is consumed but inserts nothing. Only a real browser-level keystroke updates its state.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The case that broke my own rule: editor state was also the wrong layer
&lt;/h2&gt;

&lt;p&gt;I was ready to end this article at &lt;em&gt;write to the editor state&lt;/em&gt;. Then a Tiptap composer that mirrors itself into a Redux store refused to publish.&lt;/p&gt;

&lt;p&gt;I set content through the editor API and verified everything I thought mattered: the document held 1,813 characters, the &lt;code&gt;update&lt;/code&gt; event fired, the DOM rendered the text. The request body the app sent was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"caption"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"postTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Automated text entry is a state problem, not a DOM problem"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty caption. The same text delivered through the clipboard paste route in section 2 reached the store, and the identical payload published at 200.&lt;/p&gt;

&lt;p&gt;Same visible result, one layer apart. Layer 2 was not authoritative either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify against the payload, not the page
&lt;/h2&gt;

&lt;p&gt;Hook the network before diagnosing anything:&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__cap&lt;/span&gt; &lt;span class="o"&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;open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;XMLHttpRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&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;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;XMLHttpRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;send&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;XMLHttpRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&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;open&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nx"&gt;XMLHttpRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__cap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;b&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;send&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then assert exactly one of three things: the outgoing request body contains your text; the framework document contains it (&lt;code&gt;editor.state.doc.textBetween(...)&lt;/code&gt;, &lt;code&gt;editor.getHTML()&lt;/code&gt;); or a cold reload of the page shows it. The reload is the only proof that reaches layer 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two smaller findings that cost real time
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A disabled submit button is usually a validation message that moved somewhere else.&lt;/strong&gt; In the Peerlist composer, the server's 400 string was bound to the button's &lt;code&gt;disabled&lt;/code&gt; prop, so an over-length caption presented as a permanently dead button plus a generic error. The actual limit - 480 characters - only appeared as helper text once the content was already short enough. No counter, no warning while you are over it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On React-controlled inputs, &lt;code&gt;input.value = x&lt;/code&gt; does nothing&lt;/strong&gt;, because React reads through its own descriptor and overwrites you on the next render:&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="kd"&gt;set&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOwnPropertyDescriptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HTMLTextAreaElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="kd"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;set&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;ta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&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;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&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="na"&gt;bubbles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;ta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&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;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;change&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="na"&gt;bubbles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part that is not a trick
&lt;/h2&gt;

&lt;p&gt;Every editor in this list is behaving correctly. The bug is the assumption that what you can see is what gets saved. That assumption survives for a long time, because the failure is quiet: the UI looks right the whole way through, and the only artefacts that tell the truth are the request the app sends and the page you load ten minutes later.&lt;/p&gt;

&lt;p&gt;I build ZANCTA, a set of local-first PDF and image tools that run entirely in the browser tab, so the general shape of this problem is familiar to me: if the state you are reading is a copy of the state that matters, you will be confidently wrong.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
      <category>testing</category>
    </item>
    <item>
      <title>I grepped my own app for file-upload paths. There is exactly one, and it is the contact form</title>
      <dc:creator>Lakhyajit Changmai</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:00:50 +0000</pubDate>
      <link>https://dev.to/lakhyajit/i-grepped-my-own-app-for-file-upload-paths-there-is-exactly-one-and-it-is-the-contact-form-ibm</link>
      <guid>https://dev.to/lakhyajit/i-grepped-my-own-app-for-file-upload-paths-there-is-exactly-one-and-it-is-the-contact-form-ibm</guid>
      <description>&lt;h2&gt;
  
  
  The claim, stated precisely
&lt;/h2&gt;

&lt;p&gt;ZANCTA is a browser-based PDF and image tool suite: 17 tools, all defined in one registry file. The claim on the landing page is not "we do not store your files". It is stronger and much more checkable: &lt;strong&gt;no tool ever sends file bytes to a server&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is falsifiable with a text search, and every app with a privacy claim on its landing page should run the search.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: enumerate every way bytes could leave
&lt;/h2&gt;

&lt;p&gt;Upload primitives in a browser app are few and boring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;new FormData() sent via fetch or XMLHttpRequest&lt;/li&gt;
&lt;li&gt;a File or Blob passed as a fetch body&lt;/li&gt;
&lt;li&gt;navigator.sendBeacon&lt;/li&gt;
&lt;li&gt;base64-encoding a file into a JSON field (the sneaky one)&lt;/li&gt;
&lt;li&gt;an input type=file whose contents get read and posted&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Grepping the app surface for the first three produced exactly one hit:&lt;/p&gt;

&lt;p&gt;components/contact/contact-form.tsx:31 - const data = new FormData(form)&lt;/p&gt;

&lt;p&gt;Text fields only: name, email, message. No file input in that form.&lt;/p&gt;

&lt;p&gt;Then the sneaky one. FileReader and arrayBuffer() calls are everywhere, because every tool has to read its own files. The question is what happens &lt;em&gt;after&lt;/em&gt; the read. The answer, here: pdf-lib, pdfjs-dist, browser-image-compression, libheif-js, tesseract.js, onnxruntime-web, qrcode, spark-md5, and a handful of Web Workers. All client libraries. That is the entire processing surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: audit the server side
&lt;/h2&gt;

&lt;p&gt;35 route files under app/api/**/route.ts. Sorted by what they touch: auth and session, account deletion, entitlements, payments, admin SEO integrations, analytics, contact, health, OCR language delivery, IndexNow. None of them accept a file. Zero handlers call req.formData() or read an arrayBuffer.&lt;/p&gt;

&lt;p&gt;One group looks alarming if you only read the names. Premium adds six OCR languages plus scanned-PDF OCR, and Tesseract needs .traineddata files to do that:&lt;/p&gt;

&lt;p&gt;app/api/ocr/lang-token/route.ts&lt;br&gt;
app/api/ocr/lang/[...file]/route.ts&lt;br&gt;
app/api/ocr/status/route.ts&lt;/p&gt;

&lt;p&gt;The handler is GET only. The server issues a token, then serves the language pack &lt;em&gt;down&lt;/em&gt; to the browser. Your document never goes &lt;em&gt;up&lt;/em&gt;. The direction of the arrow is the whole claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the route that does receive something about your files
&lt;/h2&gt;

&lt;p&gt;One analytics endpoint records that a tool run completed. It is the only server route that learns anything about your processing, and its guardrails are worth stealing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It accepts one field: a tool slug, validated against an allowlist built from the tool registry.&lt;/li&gt;
&lt;li&gt;MAX_BODY_BYTES is 512, checked against both the Content-Length header and the actual parsed payload. The comment in the code says why: reject anything larger rather than parsing a payload that could carry file data.&lt;/li&gt;
&lt;li&gt;Cross-origin requests are rejected before parsing.&lt;/li&gt;
&lt;li&gt;Anonymous calls are stored with userId null and no IP or user-agent, so the row cannot identify a person.&lt;/li&gt;
&lt;li&gt;Extra body fields are read and discarded, never stored.&lt;/li&gt;
&lt;li&gt;Analytics failures are swallowed so a telemetry bug can never surface as a tool failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last group is the interesting bit: the size cap is not hygiene, it is the privacy guarantee. A claim enforced by "we promise not to send more" is a claim enforced by nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: what running locally actually costs
&lt;/h2&gt;

&lt;p&gt;Nothing is free. Local processing buys privacy by moving weight onto the client:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;public/models/u2netp.onnx is 4,574,861 bytes - the background-removal model, fetched before the first removal works.&lt;/li&gt;
&lt;li&gt;public/ocr/ holds 14 WASM and worker assets.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the honest phrasing is: &lt;em&gt;your file bytes are not uploaded&lt;/em&gt;. Not: &lt;em&gt;the app needs no network&lt;/em&gt;. It loads scripts, models, and consent-gated analytics. Anyone who writes "100% offline" and "no tracking whatsoever" on a landing page is writing copy, not engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: the audit caught two real false statements
&lt;/h2&gt;

&lt;p&gt;Both were in my own documentation rather than the code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;README.md and CHANGELOG.md say "Eleven local PDF and image tools" and then list eleven. The registry has seventeen. The docs were six tools stale.&lt;/li&gt;
&lt;li&gt;README.md and the llms.txt route both say background removal is "PLANNED - deferred: no model and no cloud fallback". A 4.5 MB ONNX model ships in public/models/ and the component is fully implemented. Both statements are false today.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Neither was malicious. Both are exactly how privacy claims rot: someone writes a summary at the moment it happens to be true, the code moves, nobody re-reads the summary.&lt;/p&gt;

&lt;p&gt;There is also a live contradiction I have not resolved. This branch adds background-remover to a non-indexable list, so a tool that works is deliberately kept out of the sitemap. That is a defensible discovery decision, but it means "what the site advertises" and "what the app does" now disagree on purpose - and I have to pick one before the next public description goes out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the audit lets me publish
&lt;/h2&gt;

&lt;p&gt;Checkable: 17 tools, all processing in the tab; one FormData in the app and it belongs to the contact form; every API route accounted for by name; OCR weights served down instead of documents collected up; a 512-byte cap on the only telemetry route that touches processing.&lt;/p&gt;

&lt;p&gt;Not checkable, and competitors do it constantly: user counts, "trusted by", accuracy percentages, "unhackable", "completely anonymous". None of those have a grep.&lt;/p&gt;

&lt;p&gt;If your landing page makes a privacy claim, run the search this afternoon. The distance between what the page says and what the code does is where trust gets spent.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>What browser-local file tools actually cost you</title>
      <dc:creator>Lakhyajit Changmai</dc:creator>
      <pubDate>Fri, 16 Oct 2026 06:28:29 +0000</pubDate>
      <link>https://dev.to/lakhyajit/what-browser-local-file-tools-actually-cost-you-2iam</link>
      <guid>https://dev.to/lakhyajit/what-browser-local-file-tools-actually-cost-you-2iam</guid>
      <description>&lt;p&gt;I build &lt;a href="https://zancta.tech" rel="noopener noreferrer"&gt;ZANCTA&lt;/a&gt; - 17 PDF and image tools that run entirely in the browser. No uploads, no server-side processing. I chose that constraint on purpose, and I want to write down what it actually costs, because the pitch version of "privacy-first" hides all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Model size is a product decision, not an engineering one
&lt;/h2&gt;

&lt;p&gt;The background-removal model is an ONNX network running in WebAssembly. The equivalent server-side pipeline could ship a model an order of magnitude larger, and it would be visibly better at hair edges.&lt;/p&gt;

&lt;p&gt;I do not get to make that trade on quality alone. A user on a metered connection will close the tab before a 40 MB download finishes. So the download budget gets decided first - how long will someone realistically wait - and the quality ceiling falls out of it. Same on OCR: the model that handles a scanned document well still has to be small enough that a user will actually wait for the download.&lt;/p&gt;

&lt;p&gt;If you are shipping anything with a model to the client, your first constraint is not accuracy. It is patience.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Closing the tab is a Tuesday, not an edge case
&lt;/h2&gt;

&lt;p&gt;On a cloud PDF tool, if the user closes the tab mid-merge, the server keeps working and the file shows up in an email a few minutes later. The failure is invisible.&lt;/p&gt;

&lt;p&gt;On a browser-local tool, closing the tab kills the process. There is no fallback tier. So work that is invisible for a server-side competitor becomes mandatory here: a resumable local queue, real progress reporting, and being honest about what happens when a job is interrupted. Nobody sees any of it when the tool is working fine. You pay in development time and get no visible feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Memory is the ceiling on input size
&lt;/h2&gt;

&lt;p&gt;Everything happens inside one tab's heap. A large PDF or a high-resolution image is not a slow operation, it is frequently an impossible one. On a normal-size photo, in-browser background removal takes roughly ten seconds on desktop Chrome, which is tolerable. What I am most nervous about is iOS Safari tab-memory limits on large inputs. I have not benchmarked specifically on low-end Android yet, and I would rather measure than guess at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. What the numbers actually look like
&lt;/h2&gt;

&lt;p&gt;Publishing these because the useful version of a build log is the one with figures in it, and mine are small.&lt;/p&gt;

&lt;p&gt;Last 30 days:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;83 sessions, 533 page views&lt;/li&gt;
&lt;li&gt;17 active users in GA4, &lt;strong&gt;1&lt;/strong&gt; registered account&lt;/li&gt;
&lt;li&gt;Search Console: 45 impressions, 3 clicks, average position 13.3&lt;/li&gt;
&lt;li&gt;Revenue: 0 INR. Active subscriptions: 0&lt;/li&gt;
&lt;li&gt;Checkouts started: 1. Completed: 0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Per tool, from server and client events:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Views&lt;/th&gt;
&lt;th&gt;Started&lt;/th&gt;
&lt;th&gt;Completed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;background-remover&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;image-compress&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ocr&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pdf-text-extractor&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pdf-merge&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;images-to-pdf&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;checksum&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things that table says, and only one of them is bad news.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The product is not the bottleneck.&lt;/strong&gt; Every tool that got started got completed. One hundred percent across the board. Whatever is wrong is upstream of the tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bottleneck is that nobody arrives.&lt;/strong&gt; 45 Search Console impressions in a month, for a site in a category that is searched constantly. And look at what those impressions are for - the top queries are &lt;code&gt;sha check&lt;/code&gt;, &lt;code&gt;sha256 verify&lt;/code&gt;, and &lt;code&gt;zancot&lt;/code&gt;, which is people misspelling my own brand. I am not ranking for a single head term in the category I built for.&lt;/p&gt;

&lt;p&gt;That second one reframed the project. I put the effort into the tools and effectively none into the two things that would have made them findable.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What I got wrong about distribution
&lt;/h2&gt;

&lt;p&gt;I relaunched on Product Hunt in October with better positioning than the first attempt: one flagship capability (in-browser OCR) instead of a list of seventeen tools, a clear hook, the maker comment written in advance.&lt;/p&gt;

&lt;p&gt;It got one upvote. Mine.&lt;/p&gt;

&lt;p&gt;Nothing about the product changed between the two launches. The only variable was an audience that was not there. I had treated the launch as an event and assumed distribution follows from shipping something good. It does not. Distribution is a separate product, and I had built neither a launch audience nor a search presence. I had built a nicer landing page.&lt;/p&gt;

&lt;p&gt;The demand that converts for a file tool is search-shaped, not browse-shaped. Someone holding a tax document and a converter that just demanded an upload has the problem right now, and they are typing it into a search engine. Nobody browsing a launch directory has that problem this morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it goes from here
&lt;/h2&gt;

&lt;p&gt;The completion rates say the engineering bet was right. The impression counts say everything around it was wrong. Effort is moving off tool count and onto the small number of pages that answer a specific blocked question completely, measured by completed files rather than visits.&lt;/p&gt;

&lt;p&gt;If you have shipped a technically sound product into approximately zero demand, I would genuinely like to know what you changed first.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
