<?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: elite kid</title>
    <description>The latest articles on DEV Community by elite kid (@elite_kid_4b64c2d2d87b228).</description>
    <link>https://dev.to/elite_kid_4b64c2d2d87b228</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%2F4128367%2F216183dc-4649-4958-8575-1a7c333b89bd.png</url>
      <title>DEV Community: elite kid</title>
      <link>https://dev.to/elite_kid_4b64c2d2d87b228</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/elite_kid_4b64c2d2d87b228"/>
    <language>en</language>
    <item>
      <title>Auto-saving extension data to the Downloads folder in Manifest V3: 8 things that broke</title>
      <dc:creator>elite kid</dc:creator>
      <pubDate>Fri, 25 Sep 2026 13:00:13 +0000</pubDate>
      <link>https://dev.to/elite_kid_4b64c2d2d87b228/auto-saving-extension-data-to-the-downloads-folder-in-manifest-v3-8-things-that-broke-3jc8</link>
      <guid>https://dev.to/elite_kid_4b64c2d2d87b228/auto-saving-extension-data-to-the-downloads-folder-in-manifest-v3-8-things-that-broke-3jc8</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: this post was generated by an AI agent (Claude) with no human editing, working from the extension's source code and our own test notes. The code excerpts are copied from the repository, and the measurements come from our runs on Chrome for Testing 153 and Firefox 155 in September 2026. Every claim was checked against the code and those notes before publishing.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When a user removes a Chrome extension, its local storage goes with it. The Chrome docs say it plainly for &lt;code&gt;storage.local&lt;/code&gt;: "Data is stored locally and cleared when the extension is removed." For a tab manager, that means every saved list disappears with one click on Remove, an accidental uninstall, or a deleted browser profile.&lt;/p&gt;

&lt;p&gt;We wanted a copy that survives that: a plain JSON file in the user's Downloads folder, rewritten automatically shortly after every change, with no server, no account and no file picker. The obvious API is &lt;code&gt;downloads.download()&lt;/code&gt;. Making it reliable from a Manifest V3 background took more edge cases than we expected. The code below is from TabBunker, an open-source (MIT) tab manager for Chrome, Edge and Firefox. The source link is at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One file that is always current: &lt;code&gt;Downloads/TabBunker/tabbunker-latest.json&lt;/code&gt;, written with &lt;code&gt;conflictAction: 'overwrite'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Dated snapshots next to it (&lt;code&gt;tabbunker-20260925-0912.json&lt;/code&gt;), written with &lt;code&gt;conflictAction: 'uniquify'&lt;/code&gt;: at most one per hour, only when something changed, newest 30 kept.&lt;/li&gt;
&lt;li&gt;A write is scheduled 30 seconds (by default) after the first unsaved change, with &lt;code&gt;chrome.alarms&lt;/code&gt;, because the service worker may not be alive 30 seconds later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. No &lt;code&gt;URL.createObjectURL&lt;/code&gt; in a Chrome MV3 service worker, and Firefox rejects &lt;code&gt;data:&lt;/code&gt; URLs
&lt;/h2&gt;

&lt;p&gt;The usual way to hand a string to &lt;code&gt;downloads.download()&lt;/code&gt; is a Blob URL. In a Chrome MV3 service worker, &lt;code&gt;URL.createObjectURL&lt;/code&gt; does not exist. A &lt;code&gt;data:&lt;/code&gt; URL works there. Firefox runs the MV3 background as an event page, which does have &lt;code&gt;createObjectURL&lt;/code&gt;, and in our tests it refused a &lt;code&gt;data:&lt;/code&gt; URL download with "Access denied". So we feature-detect:&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;function&lt;/span&gt; &lt;span class="nf"&gt;makeUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&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;canBlob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createObjectURL&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;canBlob&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;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&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;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&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="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data:application/json;charset=utf-8,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&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 worried about size limits on the &lt;code&gt;data:&lt;/code&gt; path, so we measured it: JSON files of 1, 2, 4, 8, 16, 32 and 64 MB all completed in Chrome for Testing 153 (64 MB took about 1.3 seconds). Real vaults are far smaller, so we dropped the "file too large" error case from our design. On the Blob path, revoke the URL once the download settles.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;overwrite&lt;/code&gt; really overwrites, but the download history keeps growing
&lt;/h2&gt;

&lt;p&gt;We checked that &lt;code&gt;conflictAction: 'overwrite'&lt;/code&gt; behaves the same in both browsers: two downloads to the same relative path left one file on disk with the second content, and both items reported &lt;code&gt;complete&lt;/code&gt;. Every write still adds an entry to the browser's download history, though. Rewriting a file every few minutes would bury the user's real downloads, so after a new &lt;code&gt;latest&lt;/code&gt; completes we erase the history entry of the previous one:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prevLatest&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;prevLatest&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&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;await&lt;/span&gt; &lt;span class="nx"&gt;downloads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;erase&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prevLatest&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="cm"&gt;/* ignore */&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;&lt;code&gt;erase&lt;/code&gt; removes only the history entry. The file on disk is the one the new download just wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Deleting old snapshots: &lt;code&gt;removeFile&lt;/code&gt; first, then &lt;code&gt;erase&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;downloads.removeFile(id)&lt;/code&gt; deletes the file (only if it exists and the item is complete), and &lt;code&gt;downloads.erase({ id })&lt;/code&gt; deletes the history entry. The order matters: after &lt;code&gt;erase&lt;/code&gt; there is no item left, so a later &lt;code&gt;removeFile&lt;/code&gt; fails. If the user already deleted the file by hand, the item reports &lt;code&gt;exists: false&lt;/code&gt; and we only erase:&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="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;item&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exists&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;downloads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;erase&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;oldest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;downloadId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;downloads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;oldest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;downloadId&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;downloads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;erase&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;oldest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;downloadId&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;(Simplified: the real loop wraps both branches in &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;catch&lt;/code&gt;.) If deleting the same file fails three times, we stop writing new snapshots and show it, instead of looping.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. You can't find your own file by the name you gave it
&lt;/h2&gt;

&lt;p&gt;Our first idea was to look files up with &lt;code&gt;downloads.search()&lt;/code&gt;. In Chrome 153, &lt;code&gt;filenameRegex&lt;/code&gt; matches the absolute path: &lt;code&gt;TBProbe/size-1mb\.json$&lt;/code&gt; found the file, &lt;code&gt;^TBProbe/...&lt;/code&gt; found nothing, and &lt;code&gt;{ filename: 'TBProbe/size-1mb.json' }&lt;/code&gt;, the exact relative path we had passed in, returned zero results. The reliable handle is the id that &lt;code&gt;downloads.download()&lt;/code&gt; resolves with, so we store it for the in-flight write, for the current &lt;code&gt;latest&lt;/code&gt;, and for every dated snapshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. "Ask where to save each file" applies to extensions too
&lt;/h2&gt;

&lt;p&gt;In Chrome, &lt;code&gt;saveAs: false&lt;/code&gt; does not override the browser setting. With "Ask where to save each file before downloading" turned on, our extension-initiated download ended as interrupted with &lt;code&gt;USER_CANCELED&lt;/code&gt; after 277 ms (in a headless run the dialog closed at once; a real user sees a save dialog). A retry loop would pop that dialog every 30 seconds. So &lt;code&gt;USER_CANCELED&lt;/code&gt; is not retried. It pauses automatic backup until the user acts:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canceled&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveBackupState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canceled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inflight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canceled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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="k"&gt;return&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 other interrupt reasons are classified too. &lt;code&gt;USER_SHUTDOWN&lt;/code&gt; and &lt;code&gt;CRASH&lt;/code&gt; only clear the in-flight marker, so the next start writes again. Everything else (disk full, access denied, unknown) is recorded as a visible error and retried after 5 minutes, then every 30 minutes.&lt;/p&gt;

&lt;p&gt;Firefox documents that &lt;code&gt;saveAs: false&lt;/code&gt; suppresses the dialog there. We have not measured that part.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The service worker can stop in the middle of a download
&lt;/h2&gt;

&lt;p&gt;A download outlives the worker that started it. Three things made that safe for us:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store the download id right after &lt;code&gt;downloads.download()&lt;/code&gt; resolves, before waiting on anything.&lt;/li&gt;
&lt;li&gt;Register &lt;code&gt;downloads.onChanged&lt;/code&gt; synchronously at the top level of the background script. Chrome dispatches the event that woke a stopped worker to the listeners registered during that first run of the script; one added later inside an async function can miss it.&lt;/li&gt;
&lt;li&gt;Set a 2-minute watchdog alarm. When it fires, look the stored id up with &lt;code&gt;downloads.search({ id })&lt;/code&gt;. If the item finished or was interrupted, settle it. If it is still running after two minutes, record it as stalled and schedule a retry.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;browserApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;downloads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onChanged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="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;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&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="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;downloadChanged&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ready&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;items&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;downloads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;items&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;settleDownload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;restoreDownloadUiIfIdle&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;enqueue&lt;/code&gt; runs edits, alarm handlers and download events one at a time, so two handlers never read-modify-write the backup state at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. A "dirty" flag loses edits; a revision counter doesn't
&lt;/h2&gt;

&lt;p&gt;One of the design drafts we compared kept a boolean: set &lt;code&gt;dirty = true&lt;/code&gt; on every edit, set it back to &lt;code&gt;false&lt;/code&gt; when a backup completes. Walk through this: the backup of revision R starts, the user edits (now R+1, &lt;code&gt;dirty = true&lt;/code&gt;), then the R download completes and sets &lt;code&gt;dirty = false&lt;/code&gt;. The file on disk is R, the extension thinks it is up to date, and the R+1 edit waits for the next unrelated change.&lt;/p&gt;

&lt;p&gt;A monotonic revision fixes it. Every edit bumps &lt;code&gt;meta.revision&lt;/code&gt;, each write remembers the revision it captured, and completion keeps the maximum:&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;function&lt;/span&gt; &lt;span class="nf"&gt;isDirty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;revision&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;lastFileOkRevision&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// when the write that captured revision R completes:&lt;/span&gt;
&lt;span class="nl"&gt;lastFileOkRevision&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastFileOkRevision&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An edit made during the download leaves &lt;code&gt;meta.revision &amp;gt; R&lt;/code&gt;, so the next backup is still due.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Alarms: a 30-second floor, and don't re-create them on every wake-up
&lt;/h2&gt;

&lt;p&gt;Chrome MV3 alarms can't fire sooner than 30 seconds. In our measurements &lt;code&gt;delayInMinutes: 0.5&lt;/code&gt; fired after 30.1 s and &lt;code&gt;1&lt;/code&gt; after 60.0 s, so minute-level delays are precise.&lt;/p&gt;

&lt;p&gt;The subtler bug: &lt;code&gt;alarms.create()&lt;/code&gt; with an existing name replaces that alarm. Our design draft called it at the top level for a daily trash cleanup (&lt;code&gt;periodInMinutes: 1440&lt;/code&gt;). The service worker starts many times a day, so every start would push the next run another 24 hours out and the cleanup would practically never run. A review pass caught it before release, and the code checks first:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ensureTrashAlarm&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;existing&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;alarms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ALARM_TRASH&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;existing&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="nx"&gt;alarms&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="nx"&gt;ALARM_TRASH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;periodInMinutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1440&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;h2&gt;
  
  
  Also: the download bubble (Chrome and Edge only)
&lt;/h2&gt;

&lt;p&gt;A backup that pops the download bubble every few minutes is noise. Chrome and Edge have &lt;code&gt;downloads.setUiOptions({ enabled: false })&lt;/code&gt;, which needs the &lt;code&gt;downloads.ui&lt;/code&gt; permission. We turn the UI off only while an automatic backup is in flight and turn it back on when nothing is pending, including after a failure. Firefox has no equivalent API, so its downloads panel may open briefly. We tell Firefox users that once instead of hiding it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A testing trap
&lt;/h2&gt;

&lt;p&gt;We drive Chrome for Testing with Puppeteer and used the CDP command &lt;code&gt;Browser.setDownloadBehavior&lt;/code&gt; to keep test files out of the real Downloads folder. It does not apply to downloads an extension starts through the &lt;code&gt;downloads&lt;/code&gt; API: our probe files landed in the real &lt;code&gt;~/Downloads&lt;/code&gt;. A review of the test plan pointed out what that meant for the full scenario: it would overwrite the developer's own &lt;code&gt;Downloads/TabBunker/tabbunker-latest.json&lt;/code&gt;. The test scripts now switch the backup subfolder to a dedicated name (&lt;code&gt;TabBunkerSmoke-&amp;lt;pid&amp;gt;&lt;/code&gt;) and clean up only their own download ids with &lt;code&gt;removeFile&lt;/code&gt; and then &lt;code&gt;erase&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does restoring actually work?
&lt;/h2&gt;

&lt;p&gt;The file only matters if it restores. We ran the whole loop in Chrome on 2026-09-23: saved 10 tabs in a fresh profile, waited for the file, installed the extension in a second, empty profile, and imported the file. Titles, URLs and order matched. Import first shows how many groups and links the file has and asks whether to merge or replace. We have not run the same end-to-end check on Edge or Firefox yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The file is plain JSON in Downloads. Anyone who can read that folder can read the saved URLs.&lt;/li&gt;
&lt;li&gt;If the file is deleted or the disk fails, there is nothing else to restore from, and nothing syncs between devices.&lt;/li&gt;
&lt;li&gt;On Firefox the downloads panel can flash during a backup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A question for you
&lt;/h2&gt;

&lt;p&gt;If you have shipped file-based backups from an extension, how do you handle users who have "Ask where to save each file" turned on? Pausing was the least annoying option we found, but it means those users get no automatic backup until they change the setting.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/elitekid/tabbunker" rel="noopener noreferrer"&gt;https://github.com/elitekid/tabbunker&lt;/a&gt; (MIT). The backup code is in &lt;code&gt;src/background.js&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>chrome</category>
      <category>firefox</category>
    </item>
  </channel>
</rss>
