<?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: Ayesha Iftikhar</title>
    <description>The latest articles on DEV Community by Ayesha Iftikhar (@ayeshaiftikhar).</description>
    <link>https://dev.to/ayeshaiftikhar</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%2F3407569%2F81cfd268-140a-435e-88ea-73398ecf6eea.jpg</url>
      <title>DEV Community: Ayesha Iftikhar</title>
      <link>https://dev.to/ayeshaiftikhar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ayeshaiftikhar"/>
    <language>en</language>
    <item>
      <title>Compressing PDFs entirely in the browser with pdf.js, OffscreenCanvas and a Web Worker</title>
      <dc:creator>Ayesha Iftikhar</dc:creator>
      <pubDate>Wed, 30 Sep 2026 13:38:22 +0000</pubDate>
      <link>https://dev.to/ayeshaiftikhar/compressing-pdfs-entirely-in-the-browser-with-pdfjs-offscreencanvas-and-a-web-worker-203c</link>
      <guid>https://dev.to/ayeshaiftikhar/compressing-pdfs-entirely-in-the-browser-with-pdfjs-offscreencanvas-and-a-web-worker-203c</guid>
      <description>&lt;p&gt;Most "free online PDF compressor" sites work the same way: you upload the file, a server shrinks it, and you download the result. When I built &lt;a href="https://pdfperfect.org/?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=backlinks"&gt;PDF Perfect&lt;/a&gt;, I wanted the opposite. The file should never leave the user's device, and you should be able to prove that by switching your Wi-Fi off halfway through.&lt;/p&gt;

&lt;p&gt;Merging, splitting and rotating in the browser are fairly well-trodden: &lt;a href="https://pdf-lib.js.org/?utm_soruce=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=backlinks"&gt;pdf-lib&lt;/a&gt; copies pages between documents without re-encoding anything. Compression was the interesting part, so this post covers how it works, the trade-offs I accepted, and what I'd do differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why compression is hard in the browser
&lt;/h2&gt;

&lt;p&gt;Desktop tools like Ghostscript compress a PDF by walking its object tree: they downsample embedded images, subset fonts and strip unused objects. That needs a full PDF rewriter with image codecs, and there's no small, well-maintained JavaScript library that does all of it.&lt;/p&gt;

&lt;p&gt;What the browser &lt;em&gt;does&lt;/em&gt; have is an excellent PDF renderer (Mozilla's pdf.js, the one inside Firefox) and a fast JPEG encoder (&lt;code&gt;canvas.convertToBlob&lt;/code&gt;). So the approach is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Render each page to a canvas at a chosen scale.&lt;/li&gt;
&lt;li&gt;Encode the canvas as a JPEG at a chosen quality.&lt;/li&gt;
&lt;li&gt;Build a new PDF where each page is just that image.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It's a rasterising compressor. For scans and photo-heavy PDFs, which are the files people actually need to shrink, it works very well. For text documents it has a real cost, which I'll come back to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it off the main thread
&lt;/h2&gt;

&lt;p&gt;Rendering 200 pages blocks the main thread for a long time, so it all runs in a Web Worker. Workers don't have a DOM, but they do have &lt;code&gt;OffscreenCanvas&lt;/code&gt;, and pdf.js can render into one.&lt;/p&gt;

&lt;p&gt;The core loop, simplified from the real worker:&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;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pdfjsLib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDocument&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;arrayBuffer&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;promise&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;outDoc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// pdf-lib&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&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;OffscreenCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&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;1&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="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;numPages&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;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&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;viewport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getViewport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;scale&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;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&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;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&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;viewport&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;promise&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;jpeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convertToBlob&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;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&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;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;outDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embedJpg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;outDoc&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addPage&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&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="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;progress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&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="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;numPages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&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;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;outDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One canvas, reused.&lt;/strong&gt; The worker creates a single &lt;code&gt;OffscreenCanvas&lt;/code&gt; and resizes it for each page, rather than allocating a new canvas per page, which keeps memory steadier on long documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transfer, don't copy.&lt;/strong&gt; The result goes back to the main thread as an &lt;code&gt;ArrayBuffer&lt;/code&gt; in the transfer list, so a 300 MB output isn't duplicated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A timeout.&lt;/strong&gt; The main thread gives the worker 5 minutes. A tab that silently spins forever on a huge file is worse than a clear error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pdf.js worker inside my worker.&lt;/strong&gt; pdf.js wants its own worker script. Importing it with Vite's &lt;code&gt;?url&lt;/code&gt; suffix and setting &lt;code&gt;GlobalWorkerOptions.workerSrc&lt;/code&gt; works in both dev and production builds, but the server has to send &lt;code&gt;.mjs&lt;/code&gt; files as &lt;code&gt;text/javascript&lt;/code&gt;. My Apache host didn't, and the result was a very confusing production-only failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quality levels and "compress to 1 MB"
&lt;/h2&gt;

&lt;p&gt;The three presets are just (JPEG quality, render scale) pairs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Preset&lt;/th&gt;
&lt;th&gt;JPEG quality&lt;/th&gt;
&lt;th&gt;Scale&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;High Quality&lt;/td&gt;
&lt;td&gt;0.82&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Balanced&lt;/td&gt;
&lt;td&gt;0.6&lt;/td&gt;
&lt;td&gt;0.85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum&lt;/td&gt;
&lt;td&gt;0.4&lt;/td&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Users often don't care about presets, though. They have a job portal that says "max 2 MB". So there's a Target File Size mode, which walks a ladder of five settings from best to smallest and returns the &lt;strong&gt;first&lt;/strong&gt; result that fits:&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;for &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;settings&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;ladder&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;result&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;attempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;settings&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compressedSize&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;smallest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;smallest&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compressedSize&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;smallest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compressedSize&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;smallest&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;smallest&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// couldn't hit the target; show how close we got&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A binary search over quality would need fewer passes. But with only five meaningful steps and output size that doesn't change smoothly with quality, the linear ladder was simpler to reason about and gives the user the &lt;em&gt;best-looking&lt;/em&gt; file that fits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off I had to be honest about
&lt;/h2&gt;

&lt;p&gt;Rasterising means text in the compressed file is no longer text. You can't select it, search it or have a screen reader read it, and at scale 1.0 very small print looks soft when you zoom in. For a scanned form that's irrelevant. For a 40-page contract exported from Word, it's a real loss, and the file can even get &lt;em&gt;bigger&lt;/em&gt;, because vector text is tiny.&lt;/p&gt;

&lt;p&gt;So the UI shows before and after sizes up front, the docs say plainly what happens, and the guides recommend re-exporting text documents from the source app instead. I'd rather lose a user to the right tool than hand them a worse file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the file never leaves
&lt;/h2&gt;

&lt;p&gt;"We don't upload your files" is easy to claim, so I wanted it to be easy to check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Offline test:&lt;/strong&gt; load a tool, turn off Wi-Fi and process a file. It still works. (For compression, pdf.js is lazy-loaded, so run it once while online first.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network tab:&lt;/strong&gt; process a file with DevTools open. You'll see the page, fonts and analytics, and no request with a body anywhere near your file's size.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://pdfperfect.org/how-it-works?utm_source=dev.to"&gt;How it works&lt;/a&gt; page walks through both tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A structural compressor for text PDFs: downsample only the embedded images with pdf-lib and leave the text alone. It's harder, but it's the right answer for Word exports.&lt;/li&gt;
&lt;li&gt;A streaming approach for very large files, since the whole document is currently held in memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to try it, the &lt;a href="https://pdfperfect.org/compress?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=backlinks"&gt;compressor is here&lt;/a&gt;. I'd love feedback from anyone who's fought pdf.js in a worker too.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm the developer of PDF Perfect.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>pdf</category>
      <category>privacy</category>
    </item>
    <item>
      <title>How Uber Computes Estimated Time of Arrival (ETA)</title>
      <dc:creator>Ayesha Iftikhar</dc:creator>
      <pubDate>Sat, 26 Sep 2026 17:05:43 +0000</pubDate>
      <link>https://dev.to/ayeshaiftikhar/how-uber-computes-estimated-time-of-arrival-eta-7eh</link>
      <guid>https://dev.to/ayeshaiftikhar/how-uber-computes-estimated-time-of-arrival-eta-7eh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Short answer: how does Uber compute ETA?&lt;/strong&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Uber snaps noisy GPS pings to the road network (map matching), finds candidate routes on a road graph, and predicts travel time for each road segment with machine-learning models fed by real-time traffic. It then sums and corrects the route estimate, models its uncertainty, and keeps refreshing the Uber ETA as the trip progresses.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a ride-hailing platform like Uber, Estimated Time of Arrival (ETA) is not a cosmetic feature. It is a core real-time decisioning system that directly influences conversion, matching quality, pricing behavior, and user trust.&lt;/p&gt;

&lt;p&gt;An inaccurate ETA increases cancellations, reduces marketplace liquidity, and weakens reliability perception. At scale, even small percentage errors can have measurable business impact.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Problem Definition
&lt;/h2&gt;

&lt;p&gt;Uber must estimate two different time horizons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pickup ETA:&lt;/strong&gt; Time for the driver to reach the rider&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trip ETA:&lt;/strong&gt; Time from pickup to destination&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This happens under hard constraints: real-time computation, global traffic variability, sparse-data regions, continuous driver movement, and noisy GPS streams.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. System Architecture Overview
&lt;/h2&gt;

&lt;p&gt;Uber's ETA stack is a multi-layer system where each layer solves a specific sub-problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Data ingestion for live GPS and traffic signals&lt;/li&gt;
&lt;li&gt;Map matching to align noisy locations to roads&lt;/li&gt;
&lt;li&gt;Routing to generate path candidates&lt;/li&gt;
&lt;li&gt;Feature engineering for temporal and contextual signals&lt;/li&gt;
&lt;li&gt;Machine learning for segment-level travel-time prediction&lt;/li&gt;
&lt;li&gt;Online serving for low-latency ETA responses&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Data Inputs that Drive Accuracy
&lt;/h2&gt;

&lt;p&gt;ETA quality depends on combining real-time, historical, and context-rich features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;**Real-time: **driver GPS, speed vectors, congestion, live traffic feeds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Historical:&lt;/strong&gt; time-of-day trends, weekday patterns, road speed distributions, weather and holiday effects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contextual:&lt;/strong&gt; city density, special events, roadwork, and construction zones&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Map Matching and Graph Routing
&lt;/h2&gt;

&lt;p&gt;Raw GPS is inherently noisy. Before route estimation, positions are snapped to likely road segments using probabilistic map-matching methods, often including Hidden Markov Model-style inference.&lt;/p&gt;

&lt;p&gt;The road network is then represented as a weighted directed graph where intersections are nodes and road segments are edges. Edge weights represent traversal time, not just distance.&lt;/p&gt;

&lt;p&gt;Routing engines apply optimized shortest-path methods such as Dijkstra, A*, and contraction hierarchies with real-time weight updates from live traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Travel Time Estimation via Machine Learning
&lt;/h2&gt;

&lt;p&gt;Uber does not rely purely on static shortest-path output. It predicts travel time at the road-segment level using machine learning.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Current speed and congestion&lt;/li&gt;
&lt;li&gt;Historical average segment speed&lt;/li&gt;
&lt;li&gt;Time bucket and day effects&lt;/li&gt;
&lt;li&gt;Weather condition&lt;/li&gt;
&lt;li&gt;Road type and intersection complexity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Model evolution typically progresses from linear models to gradient boosting and then to deep temporal architectures that better capture time-series traffic dynamics.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Pickup ETA Computation Logic
&lt;/h2&gt;

&lt;p&gt;Pickup ETA is often harder than trip ETA because drivers may be moving, finishing another ride, or deviating in dense traffic.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Pickup ETA ≈ Remaining current segment time + optimal route time + intersection delay adjustments.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The system also computes uncertainty margins so user-facing predictions are robust under variance.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Real-Time Recalculation
&lt;/h2&gt;

&lt;p&gt;ETA is continuously refreshed every few seconds using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live GPS deltas&lt;/li&gt;
&lt;li&gt;Congestion updates&lt;/li&gt;
&lt;li&gt;Route deviation detection&lt;/li&gt;
&lt;li&gt;Dynamic re-routing logic
If traffic spikes or route conditions shift, ETA is recomputed immediately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8. Offline Training and Online Serving
&lt;/h2&gt;

&lt;p&gt;The architecture is split into two stages for scale and latency:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Offline:&lt;/strong&gt; train on billions of trips, build region-specific models, retrain continuously&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Online:&lt;/strong&gt; low-latency inference with feature store lookups, caching, and approximate serving optimizations
Production latency targets are strict, commonly sub-100 ms for inference pathways.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  9. Uncertainty and Confidence Modeling
&lt;/h2&gt;

&lt;p&gt;ETA systems must model variance explicitly rather than output a single deterministic number.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Confidence intervals&lt;/li&gt;
&lt;li&gt;Percentile ETAs (for example P50 and P90)&lt;/li&gt;
&lt;li&gt;Error calibration curves&lt;/li&gt;
&lt;li&gt;This enables messaging like &lt;strong&gt;"Arriving in 5–7 minutes"&lt;/strong&gt; and improves perceived reliability.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  10. Global Scaling Challenges
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;City diversity:&lt;/strong&gt; each city has distinct traffic behavior requiring regionalized models&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sparse regions:&lt;/strong&gt; cold-start geographies rely more on map priors, traffic APIs, and transfer learning&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPS distortion:&lt;/strong&gt; urban canyons require smoothing with Kalman filters and trajectory correction&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  11. Continuous Improvement Loop
&lt;/h2&gt;

&lt;p&gt;Uber improves ETA with a feedback cycle that combines A/B testing, prediction error pipelines, cancellation correlation analysis, and operational feedback from drivers and riders.&lt;/p&gt;

&lt;p&gt;When ETA error patterns correlate with cancellation spikes, retraining and feature adjustments are prioritized.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Business Impact
&lt;/h2&gt;

&lt;p&gt;Accurate ETA improves ride acceptance, reduces cancellations, raises driver utilization, and increases marketplace efficiency. Even small gains in ETA precision create significant revenue and trust impact at global scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Lessons for Engineers
&lt;/h2&gt;

&lt;p&gt;Do not rely only on shortest-path algorithms&lt;br&gt;
Predict at segment level instead of full-route level&lt;br&gt;
Separate heavy offline ML from low-latency online serving&lt;br&gt;
Model uncertainty explicitly, not optionally&lt;br&gt;
Retrain continuously as traffic behavior shifts&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaway
&lt;/h2&gt;

&lt;p&gt;Uber's ETA engine is far more than distance divided by speed. It is a distributed, real-time intelligence system combining graph theory, probabilistic modeling, deep learning, and infrastructure engineering under strict latency and reliability constraints.&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>machinelearning</category>
      <category>scalability</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>How I Built a Local-First Journaling App with Flutter, Drift and Firebase</title>
      <dc:creator>Ayesha Iftikhar</dc:creator>
      <pubDate>Wed, 23 Sep 2026 12:33:13 +0000</pubDate>
      <link>https://dev.to/ayeshaiftikhar/how-i-built-a-local-first-journaling-app-with-flutter-drift-and-firebase-1i34</link>
      <guid>https://dev.to/ayeshaiftikhar/how-i-built-a-local-first-journaling-app-with-flutter-drift-and-firebase-1i34</guid>
      <description>&lt;p&gt;I build &lt;a href="https://mindleau.web.app/?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=backlinks"&gt;Mindleau&lt;/a&gt;, a free brain-dump journal and mood tracker for iOS and Android. From day one, it had one hard requirement: &lt;strong&gt;you should be able to open the app and start writing without creating an account or having an internet connection.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Journal entries are some of the most private things people type, and the moment someone needs to empty their head is probably the worst possible time to show them a sign-up form.&lt;/p&gt;

&lt;p&gt;That requirement shaped the whole architecture. Here's how it's built, including the tradeoffs.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flutter&lt;/strong&gt; for iOS and Android from one codebase&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drift&lt;/strong&gt; (typed SQLite) as the source of truth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firebase Auth + Cloud Firestore&lt;/strong&gt; for optional background sync&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;provider&lt;/strong&gt; + &lt;code&gt;ChangeNotifier&lt;/code&gt; / &lt;code&gt;ValueNotifier&lt;/code&gt; for state. No Riverpod, no Bloc. The app is small enough that it didn't need them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central rule: &lt;strong&gt;the UI never talks to Firestore.&lt;/strong&gt; Every screen reads and writes through a single repository, and the repository only writes to SQLite. Sync happens afterwards, in the background.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Screens ──► SanctuaryRepository ──► Drift (SQLite)   ◄── source of truth
                      │
                      └──(background)──► SyncService ──► Firestore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  1. Every syncable table carries its own sync state
&lt;/h2&gt;

&lt;p&gt;Instead of a separate outbox table, each syncable row records whether it has been pushed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BrainDumpRows&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Table&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;IntColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;autoIncrement&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;remoteId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;DateTimeColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;createdAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;DateTimeColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;updatedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clientDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)();&lt;/span&gt;
  &lt;span class="n"&gt;DateTimeColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;deletedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;   &lt;span class="c1"&gt;// soft delete&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;mood&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;focusTagsCsv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;syncStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Constant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'synced'&lt;/span&gt;&lt;span class="p"&gt;))();&lt;/span&gt;       &lt;span class="c1"&gt;// pending_create | pending_update | synced&lt;/span&gt;
  &lt;span class="n"&gt;DateTimeColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;lastSyncedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nullable&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;Four columns carry the sync logic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;remoteId&lt;/code&gt; maps the local autoincrement ID to the Firestore document ID.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;updatedAt&lt;/code&gt; drives conflict resolution (more on that below).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deletedAt&lt;/code&gt; is a tombstone. Hard-deleting a row locally would leave nothing to tell the server it's gone.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;syncStatus&lt;/code&gt; makes "what still needs pushing?" a single indexed query.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Writes are local and synchronous; sync is fire-and-forget
&lt;/h2&gt;

&lt;p&gt;Saving a brain dump is a plain SQLite insert, marked as pending, followed by a sync attempt the UI doesn't wait for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;saveBrainDump&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;mood&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;focusTags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;into&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;brainDumpRows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;BrainDumpRowsCompanion&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;content:&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;createdAt:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;updatedAt:&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="nl"&gt;mood:&lt;/span&gt; &lt;span class="n"&gt;mood&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;focusTagsCsv:&lt;/span&gt; &lt;span class="n"&gt;_encodeList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;focusTags&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="nl"&gt;syncStatus:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'pending_create'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;syncNow&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// never blocks the save&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;id&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;The user sees their entry immediately. Whether sync then succeeds, fails, or can't run because there's no signal changes nothing on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Sync is pull-then-push, last-write-wins
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;syncNow()&lt;/code&gt; runs the same steps every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;syncNow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_canAttemptCloudSync&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ensureSignedIn&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_pullRemoteChanges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_pushPreferences&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_pushBrainDumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_pushMoodCheckIns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_pushLearnProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;);&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="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stackTrace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Sync failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;error:&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;stackTrace:&lt;/span&gt; &lt;span class="n"&gt;stackTrace&lt;/span&gt;&lt;span class="p"&gt;);&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;Pulling first means remote changes (from another device) are merged before local pending rows are pushed. The merge rule is plain &lt;strong&gt;last-write-wins on &lt;code&gt;updatedAt&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;brainDumpRows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remoteId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remoteId&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSingleOrNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Local copy is newer: keep it; it'll be pushed next.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;updatedAt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remoteUpdatedAt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tradeoff:&lt;/strong&gt; LWW can lose an edit if the same entry is changed on two offline devices. For a personal journal, where one person is almost always on one device at a time, that's acceptable. A CRDT would add a lot of complexity to prevent a conflict that almost never happens here.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Anonymous auth first, email later
&lt;/h2&gt;

&lt;p&gt;"No account required" and "sync across devices" pull in opposite directions. Firebase anonymous auth solves it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On first sync, the app signs in anonymously. The user never sees a form, but their data has a stable &lt;code&gt;users/{uid}&lt;/code&gt; path in Firestore.&lt;/li&gt;
&lt;li&gt;If they later add an email, the anonymous account is &lt;strong&gt;linked&lt;/strong&gt; to an email-link credential, so the same UID and data carry over.&lt;/li&gt;
&lt;li&gt;On a new phone, the email link restores that account.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If anonymous auth is disabled or unreachable, &lt;code&gt;ensureSignedIn()&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; and sync is skipped. The app keeps working from SQLite either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Bound every network call
&lt;/h2&gt;

&lt;p&gt;This bug took me a while to understand. Firestore and Firebase Auth calls &lt;strong&gt;have no built-in timeout&lt;/strong&gt;. On a flaky connection that isn't fully offline, an &lt;code&gt;await&lt;/code&gt; can hang forever, leaving a spinner with no way out. Every network call now goes through one wrapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;_networkTimeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;seconds:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_withTimeout&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;_networkTimeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;onTimeout:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NetworkTimeoutException&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;Because the UI never awaits sync, a timeout just means "try again later". When connectivity comes back, a listener calls &lt;code&gt;syncNow()&lt;/code&gt; and every row that isn't &lt;code&gt;synced&lt;/code&gt; goes up.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Migrations are part of the product
&lt;/h2&gt;

&lt;p&gt;Moving from "local only" to "local-first with sync" meant adding sync columns to tables that already held real user data. Drift's &lt;code&gt;MigrationStrategy&lt;/code&gt; makes this explicit and versioned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="nd"&gt;@override&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;schemaVersion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@override&lt;/span&gt;
&lt;span class="n"&gt;MigrationStrategy&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;migration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MigrationStrategy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;onUpgrade:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&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="n"&gt;from&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brainDumpRows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brainDumpRows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remoteId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brainDumpRows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brainDumpRows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;syncStatus&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="c1"&gt;// ...&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="n"&gt;from&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stillnessSessionRows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&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;One lesson: SQLite won't add a &lt;code&gt;NOT NULL&lt;/code&gt; column without a SQL-level default, and Drift's &lt;code&gt;clientDefault&lt;/code&gt; only exists in Dart. So &lt;code&gt;updated_at&lt;/code&gt; needed a raw &lt;code&gt;ALTER TABLE ... ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0&lt;/code&gt; for the rows already on users' phones.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Not everything has to sync
&lt;/h2&gt;

&lt;p&gt;Guided stillness sessions (a timer-based breathing pause) are &lt;strong&gt;local-only&lt;/strong&gt; on purpose. They only feed streaks and the 30-day heatmap, so syncing them wasn't worth the extra code and the extra data leaving the device. Deciding what &lt;em&gt;not&lt;/em&gt; to sync is part of the privacy design.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with the sync columns.&lt;/strong&gt; Adding them in migration v2 was avoidable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store tags as a JSON column, not CSV.&lt;/strong&gt; CSV was quick at first and has been awkward to work with since.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the timeout wrapper on day one.&lt;/strong&gt; Hanging awaits are the worst kind of offline bug because they only show up on bad networks, never at your desk.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;If you're building anything personal (journals, habit trackers, notes), local-first is less work than it sounds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make SQLite the source of truth.&lt;/li&gt;
&lt;li&gt;Put a &lt;code&gt;syncStatus&lt;/code&gt; and &lt;code&gt;updatedAt&lt;/code&gt; on every row.&lt;/li&gt;
&lt;li&gt;Never let the UI wait for the network.&lt;/li&gt;
&lt;li&gt;Use anonymous auth so "no account" and "sync" can coexist.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can see the result in &lt;a href="https://mindleau.web.app/?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=backlinks"&gt;Mindleau&lt;/a&gt;. It's free on iOS and Android, and you can start writing without signing up. Questions about the sync design are welcome in the comments.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>firebase</category>
      <category>architecture</category>
      <category>mobile</category>
    </item>
    <item>
      <title>How to Generate a Secure JWT Secret Key in Node.js</title>
      <dc:creator>Ayesha Iftikhar</dc:creator>
      <pubDate>Wed, 13 Aug 2025 10:35:52 +0000</pubDate>
      <link>https://dev.to/ayeshaiftikhar/how-to-generate-a-secure-jwt-secret-key-in-nodejs-335f</link>
      <guid>https://dev.to/ayeshaiftikhar/how-to-generate-a-secure-jwt-secret-key-in-nodejs-335f</guid>
      <description>&lt;p&gt;When working with JWT (JSON Web Tokens) in a Node.js application, your secret key plays a critical role in signing and verifying tokens.&lt;/p&gt;

&lt;p&gt;A weak or predictable key can compromise your application’s security, so it’s best to generate it randomly.&lt;/p&gt;

&lt;p&gt;Here’s how you can easily create a strong JWT secret key using Node.js:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add Script to &lt;code&gt;package.json&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Open your package.json and inside the "scripts" section, add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"scripts": {
  "jwt-key": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\""
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Run the script&lt;/strong&gt;&lt;br&gt;
In your terminal run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm run jwt-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a47f7c20e4f9a87c6d0af6e6c8c4bc25d2a8e7c4a9f5c30f72db7d9a48f1c3d2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy that string into your &lt;code&gt;.env&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you use this often, you can even combine it with a direct write to .env so it replaces the JWT secret automatically:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"scripts": {
  "jwt-key": "echo JWT_SECRET=$(node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\") &amp;gt; .env"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will overwrite &lt;code&gt;.env&lt;/code&gt; with the new secret each time.&lt;/p&gt;

</description>
      <category>node</category>
      <category>jwt</category>
      <category>npm</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
