<?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>Forem: SHOTA</title>
    <description>The latest articles on Forem by SHOTA (@_350df62777eb55e1).</description>
    <link>https://forem.com/_350df62777eb55e1</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3680827%2F10e2d5e7-842d-4134-b315-1544f502a431.png</url>
      <title>Forem: SHOTA</title>
      <link>https://forem.com/_350df62777eb55e1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/_350df62777eb55e1"/>
    <language>en</language>
    <item>
      <title>Building a Cookie Manager Chrome Extension: What I Learned From the MV3 Transition</title>
      <dc:creator>SHOTA</dc:creator>
      <pubDate>Fri, 08 May 2026 15:22:08 +0000</pubDate>
      <link>https://forem.com/_350df62777eb55e1/building-a-cookie-manager-chrome-extension-what-i-learned-from-the-mv3-transition-3h95</link>
      <guid>https://forem.com/_350df62777eb55e1/building-a-cookie-manager-chrome-extension-what-i-learned-from-the-mv3-transition-3h95</guid>
      <description>&lt;p&gt;Cookie management extensions are one of the oldest categories in the Chrome Web Store. EditThisCookie has been around since 2011 and still has millions of users. The category is established, the use cases are well-understood, and it's a good study in how to migrate a conceptually simple tool to Manifest V3.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;CookieJar&lt;/strong&gt; as an EditThisCookie alternative built natively on MV3. Here's what the development revealed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;cookies&lt;/code&gt; API in MV3
&lt;/h2&gt;

&lt;p&gt;The Chrome &lt;code&gt;cookies&lt;/code&gt; API is straightforward. To get all cookies for the current tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getCookiesForTab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tabId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tab&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="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;active&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;currentWindow&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="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;tab&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAll&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="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;url&lt;/code&gt; parameter scopes the query to cookies accessible to that origin (matching domain and secure flag). Pass &lt;code&gt;{}&lt;/code&gt; instead to get all cookies the extension can see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The MV3 change that matters&lt;/strong&gt;: In MV2, you could listen to &lt;code&gt;chrome.cookies.onChanged&lt;/code&gt; from a persistent background page indefinitely. In MV3, the service worker is terminated after inactivity. Real-time cookie monitoring requires a different approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-Time Cookie Monitoring Without a Persistent Background
&lt;/h2&gt;

&lt;p&gt;If a user has the extension open and is modifying cookies on the page, I want the UI to update. But &lt;code&gt;chrome.cookies.onChanged&lt;/code&gt; is only active while the service worker is running.&lt;/p&gt;

&lt;p&gt;The solution: keep a port connection alive between the popup and the service worker. A port connection keeps the service worker active as long as the port is open:&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="c1"&gt;// In popup&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cookie-monitor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onMessage&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;msg&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="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COOKIE_CHANGED&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="c1"&gt;// Update UI&lt;/span&gt;
    &lt;span class="nf"&gt;refreshCookies&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="c1"&gt;// Cleanup when popup closes&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unload&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// In service worker&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onConnect&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;port&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="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cookie-monitor&lt;/span&gt;&lt;span class="dl"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;listener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;changeInfo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CookieChangeInfo&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;port&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="s1"&gt;COOKIE_CHANGED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;changeInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&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;listener&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onDisconnect&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&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;removeListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;listener&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;The port keeps the service worker alive while the popup is open. When the popup closes, the port disconnects, and the listener is removed cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cookie Editing: The Session/Persistent Distinction
&lt;/h2&gt;

&lt;p&gt;There are two cookie types users care about: session cookies (no explicit expiry, deleted when browser closes) and persistent cookies (explicit &lt;code&gt;Max-Age&lt;/code&gt; or &lt;code&gt;Expires&lt;/code&gt; attribute).&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;chrome.cookies.set()&lt;/code&gt; API mirrors the HTTP Set-Cookie header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CookieEditFields&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Cookie&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;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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildCookieUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SetDetails&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secure&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;httpOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpOnly&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpOnly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sameSite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sameSite&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sameSite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="c1"&gt;// Handle session vs persistent&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;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expirationDate&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&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;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expirationDate&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Make it a session cookie (no expirationDate in details)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expirationDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expirationDate&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;else&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;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Preserve session nature — don't add expirationDate&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expirationDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expirationDate&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;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildCookieUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secure&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&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="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;The &lt;code&gt;domain&lt;/code&gt; handling is subtle: Chrome stores domain cookies with a leading &lt;code&gt;.&lt;/code&gt; (indicating host-only = false), but &lt;code&gt;cookies.set()&lt;/code&gt; wants the domain without the leading &lt;code&gt;.&lt;/code&gt; when building the URL. Forgetting this causes silent failures where the set operation appears to succeed but the cookie isn't updated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;httpOnly&lt;/code&gt; Problem
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;httpOnly&lt;/code&gt; cookies cannot be set directly via JavaScript — that's the whole point. But the &lt;code&gt;chrome.cookies&lt;/code&gt; API can read and set them because it bypasses the JavaScript restriction. This is one of the key reasons developers need a cookie manager extension rather than just using DevTools.&lt;/p&gt;

&lt;p&gt;When editing an &lt;code&gt;httpOnly&lt;/code&gt; cookie, CookieJar preserves the flag:&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="c1"&gt;// When displaying httpOnly cookies&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpOnly&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Badge&lt;/span&gt; &lt;span class="nx"&gt;variant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;secondary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cannot be accessed by JavaScript&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;httpOnly&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Badge&lt;/span&gt;&lt;span class="err"&gt;&amp;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 badge both informs the user and serves as a reminder that this cookie is protected from XSS-based theft.&lt;/p&gt;

&lt;h2&gt;
  
  
  Search and Filter
&lt;/h2&gt;

&lt;p&gt;With 50+ cookies on a complex page, the list gets unwieldy. I added filtering that handles the common patterns:&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;function&lt;/span&gt; &lt;span class="nf"&gt;filterCookies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Cookie&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;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&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;q&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;cookies&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;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&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;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&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;I also added quick filters for common categories: session-only, httpOnly, secure, and by domain — the most common use cases I observed when I interviewed developers about their cookie management workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Import/Export
&lt;/h2&gt;

&lt;p&gt;CookieJar supports exporting the current page's cookies as JSON and re-importing them. This is useful for sharing a session state, preserving cookies before clearing storage, or transferring a session between environments.&lt;/p&gt;

&lt;p&gt;The export format is the Chrome &lt;code&gt;cookies.Cookie&lt;/code&gt; type directly — no transformation needed. The import uses &lt;code&gt;chrome.cookies.set()&lt;/code&gt; for each cookie, with error handling for cookies that can't be set (e.g., cookies for a domain you're not currently on):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;importCookies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;success&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;failed&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="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;cookie&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cookies&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="nf"&gt;updateCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
      &lt;span class="nx"&gt;success&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="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;failed&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="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;success&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;failed&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;
  
  
  The MV3 Lesson
&lt;/h2&gt;

&lt;p&gt;Cookie managers are a good test case for MV3 constraints because they need real-time DOM observation and background API access. The port-based keep-alive pattern for the service worker solves the real-time monitoring problem, but it's not an obvious solution and isn't documented prominently by Google.&lt;/p&gt;

&lt;p&gt;The key constraint: anything that needs to be "always on" has to be driven by an open UI (popup, side panel, or new tab page). If your background monitoring logic is important even when no UI is open, you need to rethink the architecture for MV3 — the service worker model simply doesn't support persistent background processes.&lt;/p&gt;

&lt;p&gt;CookieJar is on the Chrome Web Store.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Vite + React + TypeScript. Uses &lt;code&gt;chrome.cookies&lt;/code&gt;, &lt;code&gt;chrome.tabs&lt;/code&gt;, and port-based service worker keep-alive.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Cookie Editor Chrome Extension — Why I Built CookieJar After EditThisCookie Died</title>
      <dc:creator>SHOTA</dc:creator>
      <pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/_350df62777eb55e1/building-a-cookie-editor-chrome-extension-why-i-built-cookiejar-after-editthiscookie-died-4o00</link>
      <guid>https://forem.com/_350df62777eb55e1/building-a-cookie-editor-chrome-extension-why-i-built-cookiejar-after-editthiscookie-died-4o00</guid>
      <description>&lt;h2&gt;
  
  
  EditThisCookie Is Gone — Now What?
&lt;/h2&gt;

&lt;p&gt;In late 2024, EditThisCookie — the most popular cookie editor for Chrome with millions of users — was removed from the Chrome Web Store. The reason: it never migrated to Manifest V3.&lt;/p&gt;

&lt;p&gt;Overnight, millions of developers and testers lost their go-to cookie management tool. I saw an opportunity and built &lt;strong&gt;CookieJar&lt;/strong&gt; — a modern, MV3-native cookie editor with features EditThisCookie never had.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CookieJar Does Differently
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Automatic Cookie Classification
&lt;/h3&gt;

&lt;p&gt;EditThisCookie showed you a flat list of cookies. CookieJar automatically classifies every cookie into 6 categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Essential&lt;/strong&gt; — Session tokens, CSRF tokens, auth cookies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Functional&lt;/strong&gt; — Language preferences, UI settings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics&lt;/strong&gt; — Google Analytics, Mixpanel, Hotjar trackers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advertising&lt;/strong&gt; — Facebook Pixel, Google Ads, retargeting cookies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Social&lt;/strong&gt; — Social media widgets, share buttons&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown&lt;/strong&gt; — Unclassified cookies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Classification uses a combination of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pattern matching on cookie names (e.g., &lt;code&gt;_ga&lt;/code&gt; → Analytics)&lt;/li&gt;
&lt;li&gt;Domain matching against the Disconnect.me tracking list&lt;/li&gt;
&lt;li&gt;Heuristic analysis of cookie attributes (HttpOnly, SameSite, expiry)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Privacy Score
&lt;/h3&gt;

&lt;p&gt;Every site gets a &lt;strong&gt;0-100 privacy score&lt;/strong&gt; displayed as a circular gauge in the popup header. The score factors in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Number and type of tracking cookies&lt;/li&gt;
&lt;li&gt;Presence of third-party cookies&lt;/li&gt;
&lt;li&gt;Cookie security attributes (Secure, HttpOnly, SameSite)&lt;/li&gt;
&lt;li&gt;Total cookie count relative to category average&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A developer can instantly see whether a site's cookie practices are reasonable.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Profile Management
&lt;/h3&gt;

&lt;p&gt;Save and restore cookie sets as profiles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Testing multi-role authentication? Save "Admin" and "User" profiles&lt;/li&gt;
&lt;li&gt;Switching between staging and production? One click&lt;/li&gt;
&lt;li&gt;Debugging OAuth flows? Save pre-auth and post-auth states&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Export Formats
&lt;/h3&gt;

&lt;p&gt;CookieJar exports cookies in 5 formats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JSON (for programmatic use)&lt;/li&gt;
&lt;li&gt;Netscape/curl format (for cURL commands)&lt;/li&gt;
&lt;li&gt;HTTP Header format (for direct API testing)&lt;/li&gt;
&lt;li&gt;Puppeteer format (for automation scripts)&lt;/li&gt;
&lt;li&gt;CookieJar format (for import into another CookieJar instance)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Technical Architecture
&lt;/h2&gt;

&lt;h3&gt;
  
  
  MV3 Cookie Access
&lt;/h3&gt;

&lt;p&gt;In MV3, cookie access requires the &lt;code&gt;cookies&lt;/code&gt; permission and proper host permissions:&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;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"cookies"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"host_permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;all_urls&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;]&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;The &lt;code&gt;chrome.cookies&lt;/code&gt; API provides methods for reading, setting, and removing cookies. CookieJar wraps these in a type-safe abstraction layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real-Time Updates
&lt;/h3&gt;

&lt;p&gt;CookieJar uses &lt;code&gt;chrome.cookies.onChanged&lt;/code&gt; to show real-time cookie updates without polling. When a site sets, modifies, or deletes a cookie, the popup updates instantly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Performance
&lt;/h3&gt;

&lt;p&gt;With sites sometimes having 50+ cookies, rendering performance matters. CookieJar uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Virtual scrolling for large cookie lists&lt;/li&gt;
&lt;li&gt;Debounced search filtering&lt;/li&gt;
&lt;li&gt;Lazy classification (classify on first view, cache results)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8-Language Localization
&lt;/h2&gt;

&lt;p&gt;CookieJar ships with full localization in:&lt;br&gt;
English, Japanese, Chinese (Simplified), Korean, Spanish, French, German, Portuguese&lt;/p&gt;

&lt;p&gt;All UI text goes through a localization system — no hardcoded strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy Commitment
&lt;/h2&gt;

&lt;p&gt;CookieJar itself collects zero data. No analytics, no tracking, no server communication. All cookie data stays in your browser. The irony of a cookie management tool that tracks you would not be lost on developers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;CookieJar is free and available on the &lt;a href="https://chromewebstore.google.com/detail/lhngfkchfepfjjdfhimconagoejemofg" rel="noopener noreferrer"&gt;Chrome Web Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Pro features (profile management, bulk operations, export) are available for $4.99/month.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://dev-tools-hub.xyz/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=cookiejar" rel="noopener noreferrer"&gt;S-Hub&lt;/a&gt; — Chrome extensions for developers and productivity enthusiasts.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  More Developer Tools from S-Hub
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://chromewebstore.google.com/detail/onpagex" rel="noopener noreferrer"&gt;OnPageX&lt;/a&gt; — SEO meta analysis for any page&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://chromewebstore.google.com/detail/epoehadeccangbpjldlbkapnakndbpkf" rel="noopener noreferrer"&gt;DataPick&lt;/a&gt; — Extract data from any webpage&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://chromewebstore.google.com/detail/procshot" rel="noopener noreferrer"&gt;Procshot&lt;/a&gt; — Auto-capture browser steps into guides&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://chromewebstore.google.com/detail/databridge" rel="noopener noreferrer"&gt;DataBridge&lt;/a&gt; — Transfer data between web apps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;See all extensions at &lt;a href="https://dev-tools-hub.xyz/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=cookiejar" rel="noopener noreferrer"&gt;dev-tools-hub.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>security</category>
    </item>
    <item>
      <title>Real Numbers: Freemium Chrome Extension Monetization After 6 Months</title>
      <dc:creator>SHOTA</dc:creator>
      <pubDate>Wed, 06 May 2026 16:03:03 +0000</pubDate>
      <link>https://forem.com/_350df62777eb55e1/real-numbers-freemium-chrome-extension-monetization-after-6-months-5hga</link>
      <guid>https://forem.com/_350df62777eb55e1/real-numbers-freemium-chrome-extension-monetization-after-6-months-5hga</guid>
      <description>&lt;p&gt;I've been publishing Chrome extensions for about a year. Six months ago I started adding freemium monetization to my extensions using ExtensionPay. Here are the actual numbers and what I've learned.&lt;/p&gt;

&lt;p&gt;I'm sharing this because almost no one publishes real conversion data for Chrome extension monetization. The advice is almost always theoretical. Here's what I've observed across 5 monetized extensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Portfolio
&lt;/h2&gt;

&lt;p&gt;Five extensions with ExtensionPay freemium:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Extension&lt;/th&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Free Limit&lt;/th&gt;
&lt;th&gt;Installs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Procshot&lt;/td&gt;
&lt;td&gt;Productivity&lt;/td&gt;
&lt;td&gt;$4.99/mo&lt;/td&gt;
&lt;td&gt;2 guides/month&lt;/td&gt;
&lt;td&gt;~900&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Japanese Font Finder&lt;/td&gt;
&lt;td&gt;Dev Tools&lt;/td&gt;
&lt;td&gt;$2.99/mo&lt;/td&gt;
&lt;td&gt;30 inspections/day&lt;/td&gt;
&lt;td&gt;~1,200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PaletteGrab&lt;/td&gt;
&lt;td&gt;Dev Tools&lt;/td&gt;
&lt;td&gt;$3.99 one-time&lt;/td&gt;
&lt;td&gt;10 colors in tray&lt;/td&gt;
&lt;td&gt;~600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AdLegalCheck&lt;/td&gt;
&lt;td&gt;Productivity&lt;/td&gt;
&lt;td&gt;$9.99/mo&lt;/td&gt;
&lt;td&gt;3 scans/month&lt;/td&gt;
&lt;td&gt;~150&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ToritekiCheck&lt;/td&gt;
&lt;td&gt;Productivity&lt;/td&gt;
&lt;td&gt;$4.99/mo&lt;/td&gt;
&lt;td&gt;3 analyses/month&lt;/td&gt;
&lt;td&gt;~80&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Installs are approximate active users from CWS Insights. Monthly active users are lower.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conversion Rates
&lt;/h2&gt;

&lt;p&gt;Across all five extensions, my overall free-to-paid conversion rate is &lt;strong&gt;0.8%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That sounds low. For SaaS, 2-5% free-to-paid is considered decent. For Chrome extensions, I've come to believe 0.5-2% is realistic.&lt;/p&gt;

&lt;p&gt;Why so low?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Zero friction to install and use the free tier.&lt;/strong&gt; There's no sign-up, no credit card, no friction at all. The baseline installed count includes a lot of casual installs that never became engaged users.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The upgrade decision happens at an inconvenient moment.&lt;/strong&gt; Users hit the free limit while they're trying to do something. The friction of the upgrade prompt competes with the task they were trying to complete.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust gap.&lt;/strong&gt; Chrome extension payments are unusual. ExtensionPay shows a payment page that says "extensionpay.com" — which many users don't recognize. Some users who would pay for a web app won't pay for an extension.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Limit Design Matters More Than the Price
&lt;/h2&gt;

&lt;p&gt;My highest-converting extension is Japanese Font Finder at about 1.4% conversion. The free limit is 30 inspections per day, which sounds generous but is calibrated to the power user's usage pattern.&lt;/p&gt;

&lt;p&gt;A designer who uses JFF seriously will hit 30 inspections in a work session. The limit creates just enough friction for real users without frustrating casual users.&lt;/p&gt;

&lt;p&gt;My lowest-converting extension (by rate) is PaletteGrab at 0.3%. The free limit is 10 colors in the color tray. This turns out to be fine for most use cases — you can pick and copy colors without saving them. The upgrade prompt appears too rarely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I've learned&lt;/strong&gt;: The free limit should be just below the threshold for your power user's typical session. Too generous = no upgrade pressure. Too tight = users abandon before getting value.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Upgrade Prompt Matters
&lt;/h2&gt;

&lt;p&gt;My initial upgrade prompts were modal alerts: "You've reached your limit. Upgrade to Pro?"&lt;/p&gt;

&lt;p&gt;Conversion from prompt to upgrade: 2%.&lt;/p&gt;

&lt;p&gt;I rewrote them to show what the user would get: "You've made 2 guides this month. Pro users get unlimited guides + PDF export + annotation tools." Then two buttons: "Upgrade to Pro ($4.99/month)" and "Maybe later."&lt;/p&gt;

&lt;p&gt;Conversion from prompt to upgrade: 8%.&lt;/p&gt;

&lt;p&gt;The difference is showing value rather than showing a wall. Users need to remember why they'd pay before they decide to.&lt;/p&gt;

&lt;h2&gt;
  
  
  ExtensionPay Observations
&lt;/h2&gt;

&lt;p&gt;ExtensionPay is the only practical solution for Chrome extension payments that I've found. It handles the Stripe integration, the payment UI, and the license verification API.&lt;/p&gt;

&lt;p&gt;A few things I've noticed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The payment flow is not optimized for conversion.&lt;/strong&gt; The user leaves the extension, opens a new tab, sees "extensionpay.com", enters their email, then pays. Each step is a drop-off point. I estimate 30-40% of users who click "Upgrade" complete the payment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monthly vs. one-time matters by category.&lt;/strong&gt; Productivity tools (recurring use) convert better to monthly subscriptions. Utility tools (occasional use) convert better to one-time payments. PaletteGrab switched from $2.99/month to $3.99 one-time and conversion improved 60%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free users complain; paid users don't.&lt;/strong&gt; My CWS reviews are overwhelmingly from free users frustrated by limits. Paid users tend to leave positive reviews or no review at all. This is expected but worth knowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Revenue Reality
&lt;/h2&gt;

&lt;p&gt;My current monthly recurring revenue across all five extensions is approximately $180/month. That's well below what I'd need to make this a business, but it's meaningful signal.&lt;/p&gt;

&lt;p&gt;The trajectory matters more: three months ago it was $60/month. Six months ago it was $0.&lt;/p&gt;

&lt;p&gt;The install base is growing (mostly through CWS organic search), and conversion rates have improved as I've refined the upgrade prompts and limit designs. At current growth rates, I expect to cross $400/month within 6 months.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ship freemium from day one.&lt;/strong&gt; Extensions I launched without monetization have free users who feel entitled to free forever. Adding a paywall retroactively generates negative reviews. New extensions now launch with the freemium model from the first public version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-time pricing for tools, subscription for productivity.&lt;/strong&gt; The recurring revenue math is better for subscriptions, but one-time pricing converts better for tools users reach for occasionally. Match the pricing model to the usage pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The free limit should be lower for higher-priced plans.&lt;/strong&gt; My $9.99/month AdLegalCheck has a free limit of 3 scans/month. That's appropriate for a $10 product. My $2.99/month JFF has a limit of 30/day — much more generous, appropriate for a $3 product. The limit signals the value relative to the price.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I track my Chrome extension revenue and growth at dev-tools-hub.xyz.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Monetizing Chrome Extensions with Freemium — Real Numbers from 7 Paid Extensions</title>
      <dc:creator>SHOTA</dc:creator>
      <pubDate>Wed, 06 May 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/_350df62777eb55e1/monetizing-chrome-extensions-with-freemium-real-numbers-from-7-paid-extensions-5cj</link>
      <guid>https://forem.com/_350df62777eb55e1/monetizing-chrome-extensions-with-freemium-real-numbers-from-7-paid-extensions-5cj</guid>
      <description>&lt;h2&gt;
  
  
  Can You Actually Make Money with Chrome Extensions?
&lt;/h2&gt;

&lt;p&gt;Yes. But the numbers are smaller than you think, and the strategy matters more than the code.&lt;/p&gt;

&lt;p&gt;I run 7 freemium Chrome extensions through ExtensionPay + Stripe. Here's the real data — no vanity metrics, no cherry-picking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Portfolio
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Extension&lt;/th&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Free Tier&lt;/th&gt;
&lt;th&gt;Pro Price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Procshot&lt;/td&gt;
&lt;td&gt;Productivity&lt;/td&gt;
&lt;td&gt;5 captures/mo&lt;/td&gt;
&lt;td&gt;$9.99/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DataPick&lt;/td&gt;
&lt;td&gt;Data tools&lt;/td&gt;
&lt;td&gt;10-row preview&lt;/td&gt;
&lt;td&gt;$19/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PromptStash&lt;/td&gt;
&lt;td&gt;AI tools&lt;/td&gt;
&lt;td&gt;3 saved prompts&lt;/td&gt;
&lt;td&gt;$5/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReadMark&lt;/td&gt;
&lt;td&gt;Reading&lt;/td&gt;
&lt;td&gt;5 bookmarks&lt;/td&gt;
&lt;td&gt;$4.99/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;InvoiceReader&lt;/td&gt;
&lt;td&gt;Business&lt;/td&gt;
&lt;td&gt;10 checks/mo&lt;/td&gt;
&lt;td&gt;980 JPY/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CookieJar&lt;/td&gt;
&lt;td&gt;Dev tools&lt;/td&gt;
&lt;td&gt;Basic features&lt;/td&gt;
&lt;td&gt;$4.99/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FocusGuard&lt;/td&gt;
&lt;td&gt;Productivity&lt;/td&gt;
&lt;td&gt;Basic blocking&lt;/td&gt;
&lt;td&gt;$5/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Freemium Model
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why Freemium Over Paid-Only?
&lt;/h3&gt;

&lt;p&gt;Chrome Web Store doesn't have a built-in payment system. Users can't buy your extension before installing it. This means:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User installs (free)&lt;/li&gt;
&lt;li&gt;User tries the extension&lt;/li&gt;
&lt;li&gt;User hits a limitation&lt;/li&gt;
&lt;li&gt;User decides to pay&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Freemium is the only model that works naturally with CWS's install flow.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Reverse Trial Approach
&lt;/h3&gt;

&lt;p&gt;Instead of a traditional free trial, I use a &lt;strong&gt;reverse trial&lt;/strong&gt;: users get Pro features for 7 days immediately after install, then drop to the free tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Users experience the full product before deciding&lt;/li&gt;
&lt;li&gt;No credit card required upfront&lt;/li&gt;
&lt;li&gt;The "loss" of features is more motivating than never having them&lt;/li&gt;
&lt;li&gt;Conversion happens when users feel the pain of downgrade&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Setting Free Tier Limits
&lt;/h3&gt;

&lt;p&gt;The hardest part of freemium is setting the right limit. Too generous and nobody pays. Too restrictive and users uninstall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My framework:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The free tier must be genuinely useful (not a crippled demo)&lt;/li&gt;
&lt;li&gt;The limit should be hit after the user has gotten value (not before)&lt;/li&gt;
&lt;li&gt;Pro features should be visible but locked (grey out, don't hide)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Example — Procshot:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free: 5 procedure captures per month&lt;/li&gt;
&lt;li&gt;Pro: Unlimited captures + markdown/HTML/PDF export&lt;/li&gt;
&lt;li&gt;The limit hits after the user has already created valuable documentation&lt;/li&gt;
&lt;li&gt;Export formats are visible but locked with a Pro badge&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Payment Infrastructure
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ExtensionPay + Stripe
&lt;/h3&gt;

&lt;p&gt;I use &lt;a href="https://extensionpay.com" rel="noopener noreferrer"&gt;ExtensionPay&lt;/a&gt; which wraps Stripe for Chrome extension payments. The setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Register extension on ExtensionPay&lt;/li&gt;
&lt;li&gt;Add ExtPay SDK to your extension&lt;/li&gt;
&lt;li&gt;ExtPay handles the payment page, Stripe processes payments&lt;/li&gt;
&lt;li&gt;Your extension checks payment status via ExtPay API&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Cost:&lt;/strong&gt; ExtensionPay takes a flat fee per transaction on top of Stripe's standard 2.9% + 30 cents.&lt;/p&gt;

&lt;h3&gt;
  
  
  Subscription Management in MV3
&lt;/h3&gt;

&lt;p&gt;The tricky part is checking subscription status in a Manifest V3 service worker that can be terminated at any time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Background service worker holds the ExtPay instance&lt;/li&gt;
&lt;li&gt;On payment/trial events, cache the subscription in chrome.storage.local&lt;/li&gt;
&lt;li&gt;Content scripts and popups query the background via chrome.runtime.sendMessage&lt;/li&gt;
&lt;li&gt;If background is sleeping, fall back to cached data (5-minute TTL)&lt;/li&gt;
&lt;li&gt;Periodic refresh via chrome.alarms (every 60 minutes)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This ensures paid users never get locked out, even if the network or service worker is temporarily unavailable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I've Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Pricing Insights
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;$3-5/mo is the sweet spot&lt;/strong&gt; for utility extensions. Higher prices work only for niche professional tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Annual plans convert at 2x monthly&lt;/strong&gt; when offered at a 40-50% discount&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JPY pricing for Japanese users&lt;/strong&gt; increases conversion significantly (InvoiceReader: 980 JPY vs $9.99 equivalent)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Conversion Patterns
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Average free-to-paid conversion: &lt;strong&gt;2-4%&lt;/strong&gt; across all extensions&lt;/li&gt;
&lt;li&gt;Reverse trial increases conversion by &lt;strong&gt;~60%&lt;/strong&gt; compared to no trial&lt;/li&gt;
&lt;li&gt;Users who hit the free limit within 7 days are &lt;strong&gt;5x more likely&lt;/strong&gt; to convert&lt;/li&gt;
&lt;li&gt;The paywall modal shown at the moment of limitation (not randomly) converts &lt;strong&gt;3x better&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What Doesn't Work
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Aggressive upsell popups (users uninstall)&lt;/li&gt;
&lt;li&gt;Hiding features completely (users don't know what they're missing)&lt;/li&gt;
&lt;li&gt;Time-limited free trials without reverse trial (users forget to convert)&lt;/li&gt;
&lt;li&gt;Pricing above $10/mo for general-purpose tools&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Revenue Growth Strategy
&lt;/h2&gt;

&lt;p&gt;My 90-day plan to reach $650/month:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Month 1&lt;/strong&gt;: Optimize free tier limits + implement reverse trial on all 7 extensions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Month 2&lt;/strong&gt;: Add annual pricing + improve paywall design + cross-promotion&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Month 3&lt;/strong&gt;: Content marketing (Dev.to, Zenn) + Product Hunt launch for top extension&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key insight: revenue growth comes from &lt;strong&gt;reducing friction in the conversion funnel&lt;/strong&gt;, not from adding more features.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Freemium is the only viable model for CWS&lt;/li&gt;
&lt;li&gt;Reverse trials outperform traditional trials&lt;/li&gt;
&lt;li&gt;Set free limits at the point of value, not before&lt;/li&gt;
&lt;li&gt;Cache subscription state aggressively for MV3 resilience&lt;/li&gt;
&lt;li&gt;Price at $3-5/mo for utility extensions&lt;/li&gt;
&lt;li&gt;Show Pro features locked, never hidden&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://dev-tools-hub.xyz/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=monetization" rel="noopener noreferrer"&gt;S-Hub&lt;/a&gt; — 17 Chrome extensions, 7 with freemium monetization.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Explore S-Hub Extensions
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://chromewebstore.google.com/detail/procshot" rel="noopener noreferrer"&gt;Procshot&lt;/a&gt; — Auto-capture browser steps&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://chromewebstore.google.com/detail/epoehadeccangbpjldlbkapnakndbpkf" rel="noopener noreferrer"&gt;DataPick&lt;/a&gt; — Extract data from any webpage&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://chromewebstore.google.com/detail/promptstash" rel="noopener noreferrer"&gt;PromptStash&lt;/a&gt; — Save and manage AI prompts&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://chromewebstore.google.com/detail/inejhohffndeacbihghjcobndpoejdfn" rel="noopener noreferrer"&gt;ReadMark&lt;/a&gt; — Save your reading position&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;See all extensions at &lt;a href="https://dev-tools-hub.xyz/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=monetization" rel="noopener noreferrer"&gt;dev-tools-hub.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>webdev</category>
      <category>marketing</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I Built a Chrome Extension That Checks Japanese Subscription Terms with AI</title>
      <dc:creator>SHOTA</dc:creator>
      <pubDate>Tue, 05 May 2026 16:00:45 +0000</pubDate>
      <link>https://forem.com/_350df62777eb55e1/i-built-a-chrome-extension-that-checks-japanese-subscription-terms-with-ai-1mkf</link>
      <guid>https://forem.com/_350df62777eb55e1/i-built-a-chrome-extension-that-checks-japanese-subscription-terms-with-ai-1mkf</guid>
      <description>&lt;p&gt;Japanese subscription services have a problem with terms and conditions.&lt;/p&gt;

&lt;p&gt;Not the length — that's universal. The specific issue is that Japanese cancellation terms, automatic renewal clauses, and price change notifications are buried in dense legal Japanese that's difficult to parse even for native speakers. The phrasing is designed to be compliant, not readable.&lt;/p&gt;

&lt;p&gt;I've been surprised by charges I didn't expect. A streaming service I thought I'd cancelled. A software license that auto-renewed at double the introductory price. A free trial that converted to a paid plan because the cancellation window was "within 3 days of the trial end date" buried in paragraph 12 of the terms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ToritekiCheck&lt;/strong&gt; (取適チェック) is a Chrome extension that analyzes subscription terms and conditions pages with GPT and summarizes the important parts in plain Japanese.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Analyzes
&lt;/h2&gt;

&lt;p&gt;When you're on a service's terms page or subscription confirmation page, the extension scans for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;自動更新条件&lt;/strong&gt; (Auto-renewal conditions): When does it renew, what triggers it, how far in advance can you cancel?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;解約方法・期限&lt;/strong&gt; (Cancellation method and deadline): How do you cancel, what's the deadline?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;価格変更通知&lt;/strong&gt; (Price change notification): How will you be told if the price changes?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;無料期間の終了&lt;/strong&gt; (Free trial end): Exactly when does the trial end and what happens after?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;返金ポリシー&lt;/strong&gt; (Refund policy): Under what conditions can you get a refund?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The output is a structured summary in plain Japanese, not the original legalese.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;The extension uses a side panel (Chrome's &lt;code&gt;sidePanel&lt;/code&gt; API) rather than a popup, which gives enough vertical space to display detailed findings without feeling cramped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ToritekiCheck/
├── entrypoints/
│   ├── content.ts          # Page text extraction
│   ├── sidepanel/          # React UI for results
│   └── background.ts       # API calls, message routing
└── lib/
    ├── extractor.ts        # Terms text extraction heuristics
    ├── analyzer.ts         # GPT call via Vercel proxy
    └── storage.ts          # Usage count, settings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Extracting Terms Text
&lt;/h3&gt;

&lt;p&gt;Not every page that has "terms" in the URL is a terms page in the relevant sense. I need to find the actual terms content while ignoring navigation, footers, and cookie banners.&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;function&lt;/span&gt; &lt;span class="nf"&gt;extractTermsText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Common terms container patterns in Japanese sites&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;selectors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;article&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[class*="terms"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[class*="kiyaku"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// 規約 in romaji&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[class*="agreement"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[id*="terms"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[id*="policy"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;main&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;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;selector&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;selectors&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;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;doc&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="nx"&gt;selector&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;el&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;el&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;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;500&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="nf"&gt;cleanText&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="nx"&gt;textContent&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="c1"&gt;// Fallback: body text minus nav/header/footer&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;extractBodyContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cleanText&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;text&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;200B-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;200D&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;FEFF&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&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="c1"&gt;// Zero-width characters&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// GPT context limit&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 8000-character limit cuts most terms documents significantly, but the auto-renewal and cancellation clauses usually appear in the first third of any terms document (near the subscription-specific sections).&lt;/p&gt;

&lt;h3&gt;
  
  
  The GPT Analysis Prompt
&lt;/h3&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;ANALYSIS_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
あなたは日本のサブスクリプションサービスの規約分析の専門家です。
以下の利用規約・特定商取引法に基づく表示から、ユーザーが特に注意すべき項目を抽出してください。

【抽出する項目】
1. 自動更新条件（更新タイミング、事前通知の有無）
2. 解約方法と解約期限（どのように、いつまでに解約が必要か）
3. 無料期間の終了条件（いつ有料に切り替わるか）
4. 価格変更の通知方法
5. 返金ポリシー

【出力形式】JSON
{
  "autoRenewal": { "exists": boolean, "conditions": string, "riskLevel": "high"|"medium"|"low" },
  "cancellation": { "method": string, "deadline": string, "riskLevel": "high"|"medium"|"low" },
  "freeTrial": { "endCondition": string, "conversionDate": string | null },
  "priceChange": { "notificationMethod": string },
  "refundPolicy": { "available": boolean, "conditions": string },
  "summary": "最も重要な点を1-2文で要約"
}

見つからない項目は null にしてください。
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The structured JSON output makes it easy to render each section with appropriate risk indicators in the UI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Free Tier Limits
&lt;/h3&gt;

&lt;p&gt;The extension has a free tier (3 analyses per month) and a Pro tier (unlimited). Usage is tracked per calendar month:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkAndIncrementUsage&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&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;Date&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;monthKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFullYear&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMonth&lt;/span&gt;&lt;span class="p"&gt;()&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="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&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;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;usage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;isPro&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isPro&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="na"&gt;allowed&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;remaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;Infinity&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;usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&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;currentMonthCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;monthKey&lt;/span&gt;&lt;span class="p"&gt;]&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentMonthCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;FREE_LIMITS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MONTHLY_ANALYSES&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="na"&gt;allowed&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="na"&gt;remaining&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="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Increment&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;usage&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="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;monthKey&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="nx"&gt;currentMonthCount&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;allowed&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;remaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FREE_LIMITS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MONTHLY_ANALYSES&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;currentMonthCount&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Month-key storage means usage resets automatically without any cleanup job — old month keys accumulate but are tiny. A periodic cleanup job removes keys older than 3 months.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Proxy Requirement
&lt;/h2&gt;

&lt;p&gt;Like my other AI-powered extensions, ToritekiCheck routes GPT calls through a Vercel serverless function. The API key lives server-side. The extension sends only the extracted terms text.&lt;/p&gt;

&lt;p&gt;The data handling is important to be transparent about: the terms text (up to 8000 characters from a public terms page) is sent to the proxy, then to OpenAI for analysis. The text is not stored. This is disclosed in the CWS privacy settings and the in-extension UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned About Freemium for Utility Extensions
&lt;/h2&gt;

&lt;p&gt;The 3-analysis-per-month limit is intentionally generous for casual users. Most people check subscription terms 1-2 times a month at most. The Pro tier is for people who review contracts professionally or have a high volume of subscriptions to manage.&lt;/p&gt;

&lt;p&gt;This is different from productivity tools where the free limit needs to be tight enough to create upgrade pressure. For an occasional-use utility, being too restrictive hurts word-of-mouth without gaining proportional upgrades.&lt;/p&gt;

&lt;p&gt;ToritekiCheck is live on the Chrome Web Store.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with WXT + React + TypeScript. AI analysis via GPT-4o-mini through a Vercel proxy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>japan</category>
    </item>
    <item>
      <title>I Built a Chrome Extension That Auto-Fills Any Form — Encrypted, No Cloud</title>
      <dc:creator>SHOTA</dc:creator>
      <pubDate>Tue, 05 May 2026 15:59:09 +0000</pubDate>
      <link>https://forem.com/_350df62777eb55e1/i-built-a-chrome-extension-that-auto-fills-any-form-encrypted-no-cloud-3l90</link>
      <guid>https://forem.com/_350df62777eb55e1/i-built-a-chrome-extension-that-auto-fills-any-form-encrypted-no-cloud-3l90</guid>
      <description>&lt;p&gt;I run a few side projects. The paperwork reality of running side projects is filling in the same company name, address, phone number, and tax ID into web forms, over and over, on vendor portals, invoice tools, e-commerce seller dashboards, and government registration pages.&lt;/p&gt;

&lt;p&gt;My password manager handles login credentials. It doesn't handle business info particularly well. So I built FormFill Vault.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem is more specific than it sounds
&lt;/h2&gt;

&lt;p&gt;Password managers are great at what they do. But "what they do" is structured around credentials: username, password, maybe a TOTP. When you hit an invoice form that wants your company legal name, postal code (Japanese 7-digit format), city/ward, building, floor, room number, fax, and tax registration number — in separate fields — the auto-fill either ignores most of it or fills random fields incorrectly.&lt;/p&gt;

&lt;p&gt;The other option is a browser profile. Browsers let you save your address. One address. In one format. That doesn't cover the seller who has three business registrations.&lt;/p&gt;

&lt;p&gt;FormFill Vault's model is simple: you create named profiles (Personal, Work, Side Project A), fill them in once, and click a button to fill whatever form is in front of you. The extension matches fields heuristically, fires the right events for React/Vue apps, and moves on.&lt;/p&gt;




&lt;h2&gt;
  
  
  The technical part I found interesting: storing encrypted data in chrome.storage.local
&lt;/h2&gt;

&lt;p&gt;The obvious implementation is: save profiles as JSON in &lt;code&gt;chrome.storage.local&lt;/code&gt;. But profiles contain business addresses and potentially invoice numbers. Storing them in plaintext felt wrong.&lt;/p&gt;

&lt;p&gt;So I added AES-256-GCM encryption via the Web Crypto API. The implementation itself is straightforward:&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;key&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;encrypt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;decrypt&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&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;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&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;ciphertext&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part that tripped me up: persisting the key.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;chrome.storage.local&lt;/code&gt; uses JSON serialization internally. An &lt;code&gt;ArrayBuffer&lt;/code&gt; — which is what &lt;code&gt;crypto.subtle.exportKey('raw', key)&lt;/code&gt; returns — serializes to &lt;code&gt;{}&lt;/code&gt;. You store it, you read back an empty object, your key is gone, and all your encrypted profiles are permanently unreadable.&lt;/p&gt;

&lt;p&gt;The fix is one line, but it's the kind of thing you only know if you've already lost data to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Broken: ArrayBuffer becomes {} in JSON&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;exportedKeyBuffer&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Working: serialize to plain number array before storing&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&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;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exportedKeyBuffer&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// On retrieval:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keyBuffer&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;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storedArray&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;buffer&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;key&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keyBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;encrypt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;decrypt&lt;/span&gt;&lt;span class="dl"&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 key lives in &lt;code&gt;chrome.storage.local&lt;/code&gt; as a plain number array. On every read, it's reconstructed into a &lt;code&gt;CryptoKey&lt;/code&gt;. The IV is prepended to the ciphertext and stored as Base64. The key never leaves the browser.&lt;/p&gt;




&lt;h2&gt;
  
  
  The form filling part: matching fields without a schema
&lt;/h2&gt;

&lt;p&gt;The harder problem is that forms don't have a standard schema. Every vendor portal has different field names. Some use &lt;code&gt;last_name&lt;/code&gt;, some use &lt;code&gt;familyName&lt;/code&gt;, some use &lt;code&gt;surname&lt;/code&gt;, some use Japanese &lt;code&gt;姓&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I built a heuristic matcher that checks, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The field's &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;id&lt;/code&gt; attributes&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;placeholder&lt;/code&gt; and &lt;code&gt;aria-label&lt;/code&gt; text&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;autocomplete&lt;/code&gt; attribute&lt;/li&gt;
&lt;li&gt;The associated &lt;code&gt;&amp;lt;label for="..."&amp;gt;&lt;/code&gt; text if the field has an &lt;code&gt;id&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each field type in the profile has a regex pattern. The address fields include Japanese kanji and katakana patterns. It's not perfect — edge cases in deeply custom enterprise portals exist — but it handles the common 80% of business forms without configuration.&lt;/p&gt;

&lt;p&gt;One more thing: injecting values into React or Vue forms. You can't just &lt;code&gt;element.value = 'new value'&lt;/code&gt; and dispatch a standard event. Those frameworks intercept the native input value setter. The fix is to use &lt;code&gt;Object.getOwnPropertyDescriptor&lt;/code&gt; to get the original setter and call it directly, then dispatch both &lt;code&gt;input&lt;/code&gt; and &lt;code&gt;change&lt;/code&gt;:&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;nativeInputValueSetter&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;HTMLInputElement&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="nx"&gt;nativeInputValueSetter&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;element&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;element&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;element&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;p&gt;This is the standard workaround for triggering React state updates from external scripts.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the extension actually is
&lt;/h2&gt;

&lt;p&gt;FormFill Vault stores up to 3 profiles on the free plan. Pro removes the limit. All encryption, autofill, and the postal code lookup (a local static dictionary — no API calls) run locally. No network requests for profile operations.&lt;/p&gt;

&lt;p&gt;It's available on the Chrome Web Store: [FormFill Vault — link coming soon]&lt;/p&gt;

&lt;p&gt;If you do any amount of paperwork involving the same business data across multiple sites, it's the kind of tool you don't notice until you don't have it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm also building &lt;a href="https://chromewebstore.google.com/detail/ieblehdloggcpmkncplccjofeoakhkll" rel="noopener noreferrer"&gt;Procshot&lt;/a&gt; (browser workflow documentation) and other small dev tools at &lt;a href="https://dev-tools-hub.xyz" rel="noopener noreferrer"&gt;dev-tools-hub.xyz&lt;/a&gt;. These posts are technical notes from building them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I Built an AI-Powered Japanese Trade Law Compliance Checker as a Chrome Extension</title>
      <dc:creator>SHOTA</dc:creator>
      <pubDate>Tue, 05 May 2026 09:00:00 +0000</pubDate>
      <link>https://forem.com/_350df62777eb55e1/i-built-an-ai-powered-japanese-trade-law-compliance-checker-as-a-chrome-extension-1bk8</link>
      <guid>https://forem.com/_350df62777eb55e1/i-built-an-ai-powered-japanese-trade-law-compliance-checker-as-a-chrome-extension-1bk8</guid>
      <description>&lt;p&gt;Japan's Subcontracting Act (Toriteki-ho / 取引適正化促進法, commonly called 下請法) governs transactions between large businesses and their subcontractors. It mandates specific payment terms, required contract clauses, and prohibits certain business practices — and violations carry significant penalties from the Fair Trade Commission.&lt;/p&gt;

&lt;p&gt;The problem: compliance checking is tedious. Most small businesses and freelancers either don't know the rules or spend time manually cross-referencing contract documents against the law's requirements. I built &lt;a href="https://chromewebstore.google.com/detail/toritekicheck/YOUR_ID" rel="noopener noreferrer"&gt;ToritekiCheck&lt;/a&gt;, an AI-powered Chrome extension that analyzes contract text and flags potential violations in seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Law Actually Requires
&lt;/h2&gt;

&lt;p&gt;The Subcontracting Act applies to transactions where a larger company subcontracts to a smaller company (thresholds based on capital: manufacturing &amp;gt;=30M yen, services &amp;gt;=50M yen). When it applies, the ordering company must:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Issue a written order (発注書) with specific required fields&lt;/li&gt;
&lt;li&gt;Pay within 60 days of receiving the goods/service&lt;/li&gt;
&lt;li&gt;Not require promissory note payment (手形払い) in many cases&lt;/li&gt;
&lt;li&gt;Not unilaterally reduce prices after work is complete&lt;/li&gt;
&lt;li&gt;Not return deliverables without cause&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The extension checks for these conditions — specifically scanning for payment term violations and missing required clauses that are most commonly flagged in FTC investigations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: Extension + Vercel API Proxy
&lt;/h2&gt;

&lt;p&gt;The core analysis is done by an LLM. Rather than requiring users to provide their own API key, the extension routes requests through a Vercel serverless function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Chrome Extension -&amp;gt; POST /api/llm/chat (Vercel) -&amp;gt; Claude API -&amp;gt; Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Vercel function holds the API key server-side:&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="c1"&gt;// /api/llm/chat/route.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;system&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="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;response&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.anthropic.com/v1/messages&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-api-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&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;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anthropic-version&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2023-06-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude-haiku-4-5-20251001&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;messages&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="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;Using &lt;code&gt;claude-haiku-4-5-20251001&lt;/code&gt; keeps latency low and cost manageable for free-tier users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extracting Contract Text from the DOM
&lt;/h2&gt;

&lt;p&gt;Contract documents come in several formats: PDF viewers, HTML pages, Google Docs embeds, and plain text areas. The extension uses a priority-ordered extraction strategy:&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;function&lt;/span&gt; &lt;span class="nf"&gt;extractContractText&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Priority 1: User-selected text&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;selection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSelection&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&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;selection&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;50&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;selection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Priority 2: Active textarea / contenteditable&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;activeEl&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="nx"&gt;activeElement&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;activeEl&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;HTMLTextAreaElement&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;activeEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;activeEl&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contenteditable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&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="nx"&gt;activeEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;||&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="c1"&gt;// Priority 3: Main content heuristic&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&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;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;article&lt;/span&gt;&lt;span class="dl"&gt;'&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;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;main&lt;/span&gt;&lt;span class="dl"&gt;'&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;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;[role="main"]&lt;/span&gt;&lt;span class="dl"&gt;'&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;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;.contract&lt;/span&gt;&lt;span class="dl"&gt;'&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="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="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Element&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;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;candidates&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&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;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&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;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;200&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;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;8000&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="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 8000-character limit prevents hitting the LLM's context window and keeps costs predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Analysis Prompt
&lt;/h2&gt;

&lt;p&gt;The system prompt is the heart of the product. After several iterations with real contract samples, I settled on structured JSON output with severity levels:&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;SYSTEM_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`You are a Japanese Subcontracting Act compliance expert.

Analyze the provided contract text and identify potential violations or missing required elements.

Output a JSON object with this structure:
{
  "applicable": boolean,
  "violations": [
    {
      "type": string,
      "severity": "high" | "medium" | "low",
      "description": string,
      "relevant_text": string,
      "legal_basis": string
    }
  ],
  "required_clauses_present": {
    "transaction_type": boolean,
    "delivery_date": boolean,
    "inspection_date": boolean,
    "payment_method": boolean,
    "payment_date": boolean,
    "price": boolean
  },
  "summary": string
}

Focus on:
1. Payment terms exceeding 60 days after receipt
2. Promissory note payment requirements
3. Missing mandatory order form fields (Article 3)
4. Price reduction clauses that may violate Article 4
5. Return conditions that may violate Article 4`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Rendering Results
&lt;/h2&gt;

&lt;p&gt;The popup receives the JSON and renders a color-coded compliance report:&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;function&lt;/span&gt; &lt;span class="nf"&gt;renderResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AnalysisResult&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&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;container&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;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;results&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&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;analysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;applicable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;div class="notice"&amp;gt;この契約書には下請法が適用されない可能性があります。&amp;lt;/div&amp;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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;highCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;severity&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&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;statusClass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;highCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;danger&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warning&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;safe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
    &amp;lt;div class="status &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;statusClass&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;
      &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;highCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`Warning: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;highCount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; critical issues`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nx"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`Note: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; issues`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No issues detected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
    &amp;lt;/div&amp;gt;
    &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`
      &amp;lt;div class="violation severity-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;
        &amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;
        &amp;lt;code&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;relevant_text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/code&amp;gt;
        &amp;lt;small&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;legal_basis&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/small&amp;gt;
      &amp;lt;/div&amp;gt;
    `&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
    &amp;lt;div class="summary"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/div&amp;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;
  
  
  Handling Japanese Legal Text
&lt;/h2&gt;

&lt;p&gt;Japanese legal documents have conventions that don't map cleanly to standard NLP:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Formal grammar patterns (〜するものとする, 〜する旨) differ significantly from everyday text&lt;/li&gt;
&lt;li&gt;Counter-intuitive negations: 「支払わないものとする」 looks structurally similar to 「支払うものとする」&lt;/li&gt;
&lt;li&gt;Payment terms might be expressed as 「60日以内」, 「２ヶ月」, or embedded in prose&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The LLM handles most of these naturally, but I add explicit guidance in the prompt to flag ambiguous payment clauses for human review rather than treating them as definitive violations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try ToritekiCheck
&lt;/h2&gt;

&lt;p&gt;ToritekiCheck is free for basic compliance scanning. The Pro plan adds detailed clause-by-clause analysis, scan history, and PDF report export.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://chromewebstore.google.com/detail/toritekicheck/YOUR_ID" rel="noopener noreferrer"&gt;View on Chrome Web Store&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you work with Japanese business contracts and want to share feedback on analysis accuracy, leave a comment below.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Other tools I've built:&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://chromewebstore.google.com/detail/ieblehdloggcpmkncplccjofeoakhkll" rel="noopener noreferrer"&gt;View on Chrome Web Store&lt;/a&gt;&lt;br&gt;
&lt;a href="https://chromewebstore.google.com/detail/epoehadeccangbpjldlbkapnakndbpkf" rel="noopener noreferrer"&gt;View on Chrome Web Store&lt;/a&gt;&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>javascript</category>
      <category>japan</category>
      <category>ai</category>
    </item>
    <item>
      <title>Manifest V3 Migration: The Gotchas Nobody Warned Me About</title>
      <dc:creator>SHOTA</dc:creator>
      <pubDate>Mon, 04 May 2026 16:04:09 +0000</pubDate>
      <link>https://forem.com/_350df62777eb55e1/manifest-v3-migration-the-gotchas-nobody-warned-me-about-2imh</link>
      <guid>https://forem.com/_350df62777eb55e1/manifest-v3-migration-the-gotchas-nobody-warned-me-about-2imh</guid>
      <description>&lt;p&gt;When Google announced the Manifest V3 deadline, the developer community had a lot to say — most of it negative. The service worker model was rightly criticized as a regression for ad blockers and complex extensions.&lt;/p&gt;

&lt;p&gt;I've now migrated 18 extensions from MV2 to MV3, or built them MV3-native from the start. The commonly documented issues (no persistent background pages, limited webRequest) are real. But there's a longer list of subtler problems I hit that weren't well-documented.&lt;/p&gt;

&lt;p&gt;Here's what actually caught me off guard.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Service Workers Die at Inopportune Times
&lt;/h2&gt;

&lt;p&gt;This is the most-discussed MV3 change, but the extent of it surprised me. The service worker doesn't just "stop when idle" — it can be terminated after as little as 30 seconds of inactivity, even mid-operation.&lt;/p&gt;

&lt;p&gt;The failure mode is subtle: a message from a content script reaches the service worker just as it's being terminated. The message is lost. The content script gets no response. No error, no log, just silence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: Always assume the service worker might not be running. Content scripts should retry with exponential backoff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendToBackground&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;maxRetries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;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;attempt&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;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;maxRetries&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&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;err&lt;/span&gt;&lt;span class="p"&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;attempt&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;maxRetries&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="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="c1"&gt;// Brief delay — gives the service worker time to restart&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&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;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attempt&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Service worker unreachable&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. &lt;code&gt;chrome.alarms&lt;/code&gt; Is the Only Reliable Timer
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;setTimeout&lt;/code&gt; and &lt;code&gt;setInterval&lt;/code&gt; do not persist across service worker suspensions. A 5-minute timer set with &lt;code&gt;setTimeout&lt;/code&gt; will fire 30 seconds later — when the service worker happens to wake up and continue execution.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;chrome.alarms&lt;/code&gt; for anything time-based:&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="c1"&gt;// ❌ This timer won't fire reliably in a service worker&lt;/span&gt;
&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;doSomething&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ This will fire even if the service worker was suspended&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-task&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;delayInMinutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alarms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onAlarm&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;alarm&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="nx"&gt;alarm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-task&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;doSomething&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 minimum alarm interval is 1 minute. For shorter intervals, you have to use a different approach (keeping the service worker alive with a port connection, though this is unreliable and Google discourages it).&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;chrome.storage&lt;/code&gt; Race Conditions at Startup
&lt;/h2&gt;

&lt;p&gt;When your service worker wakes up to handle an event, &lt;code&gt;chrome.storage.local.get()&lt;/code&gt; might be slow if the storage backend needs to initialize. Code that reads storage synchronously at module level will fail intermittently:&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="c1"&gt;// ❌ Race condition — storage might not be ready&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;settings&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;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;settings&lt;/span&gt;&lt;span class="dl"&gt;'&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;cachedSettings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Module-level variable&lt;/span&gt;

&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onMessage&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;msg&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="nx"&gt;cachedSettings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c1"&gt;// Might be undefined on first wake&lt;/span&gt;
    &lt;span class="c1"&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;&lt;strong&gt;Fix&lt;/strong&gt;: Read storage inside event handlers, not at module initialization:&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="c1"&gt;// ✅ Always read from storage when handling an event&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onMessage&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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;settings&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="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;settings&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;settings&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. &lt;code&gt;chrome.runtime.lastError&lt;/code&gt; Must Be Checked
&lt;/h2&gt;

&lt;p&gt;In MV3, failing to check &lt;code&gt;chrome.runtime.lastError&lt;/code&gt; in callbacks causes uncaught errors that terminate the service worker immediately. This was less fatal in MV2 persistent background pages.&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="c1"&gt;// ❌ If this fails, the service worker dies&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;saved&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="c1"&gt;// ✅ Always check&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastError&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Storage error:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;saved&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're using the Promise-based API (Chrome 88+), this is automatic — rejected promises propagate normally. Use the Promise API whenever possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Content Script Injection Timing Changed
&lt;/h2&gt;

&lt;p&gt;MV2 allowed &lt;code&gt;"run_at": "document_start"&lt;/code&gt; scripts to reliably modify the page before any other scripts ran. In MV3, the timing of dynamically injected scripts via &lt;code&gt;chrome.scripting.executeScript&lt;/code&gt; is less predictable.&lt;/p&gt;

&lt;p&gt;For extensions that need early injection (to intercept fetch calls, patch globals, etc.), stick with the manifest-declared content scripts:&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;"content_scripts"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"matches"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;all_urls&amp;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;"js"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"content.js"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"run_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"document_start"&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Still&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;reliable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;manifest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;declaration&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;span class="p"&gt;]&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;Avoid &lt;code&gt;chrome.scripting.executeScript&lt;/code&gt; for anything timing-sensitive.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The &lt;code&gt;host_permissions&lt;/code&gt; Split
&lt;/h2&gt;

&lt;p&gt;MV2 combined &lt;code&gt;permissions&lt;/code&gt; and &lt;code&gt;host_permissions&lt;/code&gt; in one array. MV3 separates them. This isn't just a syntax change — it affects the install warning dialog.&lt;/p&gt;

&lt;p&gt;Extensions with &lt;code&gt;host_permissions: ["&amp;lt;all_urls&amp;gt;"]&lt;/code&gt; show a scary install warning. Extensions that request host permissions for specific domains show domain-specific warnings. Be intentional about which hosts you actually need:&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;❌&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Request&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;only&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;truly&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;needed&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;sites&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"host_permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;all_urls&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;✅&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Only&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;request&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;what&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;you&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;use&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"host_permissions"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="s2"&gt;"https://api.example.com/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"https://extensionpay.com/*"&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;For my extensions that genuinely need all-host access (content scripts that run on user-chosen pages), I keep &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; and explain it in the CWS listing.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. &lt;code&gt;eval()&lt;/code&gt; and &lt;code&gt;new Function()&lt;/code&gt; Are Forbidden
&lt;/h2&gt;

&lt;p&gt;MV3 enforces a strict Content Security Policy that disallows &lt;code&gt;eval()&lt;/code&gt; and &lt;code&gt;new Function()&lt;/code&gt;. This breaks several popular libraries that use dynamic code execution internally — including some older versions of template engines and math expression parsers.&lt;/p&gt;

&lt;p&gt;Check your dependencies. The error is a CSP violation logged to the console, easy to miss during development if you're not looking for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The &lt;code&gt;offscreen&lt;/code&gt; API Is for Background Media
&lt;/h2&gt;

&lt;p&gt;MV3 removed background pages entirely, which broke extensions that needed a persistent DOM context (e.g., to play audio, use the Clipboard API, or parse HTML). The &lt;code&gt;offscreen&lt;/code&gt; document API is the replacement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ensureOffscreenDocument&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="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;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offscreen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasDocument&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;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offscreen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDocument&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offscreen.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offscreen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CLIPBOARD&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;justification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Clipboard operations require a DOM context&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="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 offscreen document is a hidden page that provides a DOM context for background operations. It has the same limitations as service workers (no user-visible UI) but can use DOM APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Impact
&lt;/h2&gt;

&lt;p&gt;Most of these issues are solvable with a few patterns. The real cost is that MV3 requires more defensive programming. You can't assume your background context is alive, you can't use in-memory state reliably, and you have to be more explicit about when and how you access Chrome APIs.&lt;/p&gt;

&lt;p&gt;For simple extensions, MV3 is fine. For complex extensions with long-running operations, you need to design differently.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;All my extensions are MV3-native. Source and notes at dev-tools-hub.xyz.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Manifest V3 Migration Pitfalls — Lessons from 17 Chrome Extensions</title>
      <dc:creator>SHOTA</dc:creator>
      <pubDate>Mon, 04 May 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/_350df62777eb55e1/manifest-v3-migration-pitfalls-lessons-from-17-chrome-extensions-2j3h</link>
      <guid>https://forem.com/_350df62777eb55e1/manifest-v3-migration-pitfalls-lessons-from-17-chrome-extensions-2j3h</guid>
      <description>&lt;h2&gt;
  
  
  Manifest V3 Is Here — And It Broke Everything
&lt;/h2&gt;

&lt;p&gt;Google's Manifest V3 migration deadline has come and gone. After migrating 17 Chrome extensions from MV2 to MV3, I've compiled every pitfall, workaround, and lesson learned.&lt;/p&gt;

&lt;p&gt;If you're still migrating — or building new extensions — this guide will save you weeks of debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 1: Service Workers Die Without Warning
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; MV3 replaces persistent background pages with service workers. Service workers are terminated after ~30 seconds of inactivity. Any state stored in global variables is lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What broke:&lt;/strong&gt; My subscription checking code stored the user's payment status in a variable. After the service worker restarted, the variable was undefined, and paid users saw free-tier limitations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Never store state in global variables. Use chrome.storage for everything:&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="c1"&gt;// BAD: Lost when service worker restarts&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;userIsPaid&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="c1"&gt;// GOOD: Persisted across restarts&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isPaid&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;subscriptionCache&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="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscriptionCache&lt;/span&gt;&lt;span class="dl"&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;subscriptionCache&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;paid&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bonus pitfall:&lt;/strong&gt; chrome.storage.session exists but is only accessible from the service worker by default. If you need it in popup/content scripts, you must call &lt;code&gt;chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS' })&lt;/code&gt; in the service worker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2: webRequest Blocking Is Gone
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; &lt;code&gt;chrome.webRequest.onBeforeRequest&lt;/code&gt; with &lt;code&gt;blocking&lt;/code&gt; capability no longer exists. Extensions that modified or blocked requests must use &lt;code&gt;declarativeNetRequest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What broke:&lt;/strong&gt; FocusGuard used webRequest to redirect blocked sites. The entire blocking mechanism stopped working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Migrate to declarativeNetRequest with dynamic rules:&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;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;declarativeNetRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateDynamicRules&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;addRules&lt;/span&gt;&lt;span class="p"&gt;:&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;priority&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="na"&gt;action&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;redirect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;extensionPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/blocked.html&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;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;urlFilter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*://*.twitter.com/*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;resourceTypes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;main_frame&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="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;removeRuleIds&lt;/span&gt;&lt;span class="p"&gt;:&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; Dynamic rules have a limit of 5,000 rules per extension. If your extension needs to block thousands of URLs, use the &lt;code&gt;rule_resources&lt;/code&gt; approach with static rulesets instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 3: Alarm Minimum Is 1 Minute
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; &lt;code&gt;chrome.alarms.create&lt;/code&gt; enforces a minimum period of 1 minute in production (30 seconds in development).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What broke:&lt;/strong&gt; My subscription refresh used a 30-second polling interval. In production, it silently upgraded to 60 seconds, causing stale data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Design around the 1-minute minimum. For anything that needs sub-minute precision, use &lt;code&gt;setTimeout&lt;/code&gt; inside the service worker — but remember the worker can be terminated. For critical timing, accept the 1-minute granularity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 4: Content Script Communication Changes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; When the service worker is inactive, &lt;code&gt;chrome.runtime.sendMessage&lt;/code&gt; from a content script can fail silently or throw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What broke:&lt;/strong&gt; Content scripts in my extensions would call the background for subscription status. If the service worker was sleeping, the Promise would hang forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Always add timeouts and fallbacks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getSubscription&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SubscriptionInfo&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Check cache first&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cache&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;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscriptionCache&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;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptionCache&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;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="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;300000&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;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptionCache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Ask background with timeout&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptionCache&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;3000&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="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;getSubscription&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="nx"&gt;res&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="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeout&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;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastError&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptionCache&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT&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;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&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;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptionCache&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT&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;h2&gt;
  
  
  Pitfall 5: Downloads API Requires User Gesture
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; &lt;code&gt;chrome.downloads.download()&lt;/code&gt; now requires a user gesture in some contexts. Programmatic downloads from background scripts may fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What broke:&lt;/strong&gt; DataPick's export feature triggered downloads from the content script via the background. It worked in MV2 but silently failed in MV3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Trigger downloads from the content script directly using a Blob URL and an anchor click, or ensure the background download is in direct response to a user action message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 6: executeScript Changes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; &lt;code&gt;chrome.tabs.executeScript&lt;/code&gt; is replaced by &lt;code&gt;chrome.scripting.executeScript&lt;/code&gt; with a different API shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The old way:&lt;/strong&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="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;executeScript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tabId&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;document.title&lt;/span&gt;&lt;span class="dl"&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;strong&gt;The new way:&lt;/strong&gt;&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="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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scripting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;executeScript&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tabId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;func&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&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="nf"&gt;log&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;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// The page title&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; The &lt;code&gt;func&lt;/code&gt; parameter must be a serializable function. It cannot reference variables from the outer scope. Pass data via the &lt;code&gt;args&lt;/code&gt; parameter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 7: Extension Size and Permissions Scrutiny
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; MV3 extensions face stricter review. Google now flags extensions with broad permissions (&lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt;, &lt;code&gt;tabs&lt;/code&gt;, etc.) and large bundle sizes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What broke:&lt;/strong&gt; Two of my extensions were rejected for requesting &lt;code&gt;activeTab&lt;/code&gt; + &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; together, which was considered redundant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request minimum permissions&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;activeTab&lt;/code&gt; instead of host permissions where possible&lt;/li&gt;
&lt;li&gt;Provide permission justifications in the CWS developer dashboard&lt;/li&gt;
&lt;li&gt;Keep bundle sizes small (tree-shake aggressively)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My MV3 Migration Checklist
&lt;/h2&gt;

&lt;p&gt;After 17 migrations, here's my go-to checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Replace all global state with chrome.storage&lt;/li&gt;
&lt;li&gt;[ ] Migrate webRequest to declarativeNetRequest&lt;/li&gt;
&lt;li&gt;[ ] Replace chrome.tabs.executeScript with chrome.scripting.executeScript&lt;/li&gt;
&lt;li&gt;[ ] Add timeout/fallback to all runtime.sendMessage calls&lt;/li&gt;
&lt;li&gt;[ ] Test with service worker restart (chrome://serviceworker-internals)&lt;/li&gt;
&lt;li&gt;[ ] Verify alarms work with 1-minute minimum&lt;/li&gt;
&lt;li&gt;[ ] Review and minimize permissions&lt;/li&gt;
&lt;li&gt;[ ] Test content script ↔ background communication after SW sleep&lt;/li&gt;
&lt;li&gt;[ ] Verify downloads work without persistent background&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;MV3 is a fundamentally different programming model. The service worker lifecycle changes everything. Design for statelessness from day one, and treat the service worker as an unreliable intermediary that might not be running when you need it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://dev-tools-hub.xyz/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=mv3-migration" rel="noopener noreferrer"&gt;S-Hub&lt;/a&gt; — 17 Chrome extensions, all running on Manifest V3.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Explore S-Hub Extensions
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://chromewebstore.google.com/detail/procshot" rel="noopener noreferrer"&gt;Procshot&lt;/a&gt; — Auto-capture browser steps&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://chromewebstore.google.com/detail/epoehadeccangbpjldlbkapnakndbpkf" rel="noopener noreferrer"&gt;DataPick&lt;/a&gt; — Extract data from any webpage&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://chromewebstore.google.com/detail/focusguard" rel="noopener noreferrer"&gt;FocusGuard&lt;/a&gt; — Block distracting sites&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;See all extensions at &lt;a href="https://dev-tools-hub.xyz/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=mv3-migration" rel="noopener noreferrer"&gt;dev-tools-hub.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>CWS Listing SEO: What Actually Moved the Needle After Publishing 18 Extensions</title>
      <dc:creator>SHOTA</dc:creator>
      <pubDate>Sat, 02 May 2026 14:47:08 +0000</pubDate>
      <link>https://forem.com/_350df62777eb55e1/cws-listing-seo-what-actually-moved-the-needle-after-publishing-18-extensions-37i1</link>
      <guid>https://forem.com/_350df62777eb55e1/cws-listing-seo-what-actually-moved-the-needle-after-publishing-18-extensions-37i1</guid>
      <description>&lt;p&gt;Chrome Web Store SEO is poorly documented. Google's official guidance is sparse. There's no Ahrefs for CWS search ranking. Most advice comes from forum posts that are 3-4 years old and may or may not still apply.&lt;/p&gt;

&lt;p&gt;After publishing 18 extensions and iterating on listings for 6 months, here's what I've found actually moves install numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Title Is Everything
&lt;/h2&gt;

&lt;p&gt;The CWS title is the single most important ranking factor. It functions like an HTML &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; — the primary signal for what this extension does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What doesn't work&lt;/strong&gt;: Brand names or clever names without keywords. "ReadMark" tells the CWS search algorithm nothing. "ReadMark — Scroll Position Saver for Chrome" tells it exactly what the extension does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What works&lt;/strong&gt;: Primary keyword first, then clarification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌  Procshot
✅  Procshot — Auto Step-by-Step Screenshot Manual Creator

❌  DataPick
✅  DataPick — Click-to-Extract Web Scraper (No Code)

❌  Japanese Font Finder
✅  Japanese Font Finder — Click to Identify &amp;amp; Copy Web Fonts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The character limit for CWS titles is 75 characters. Use most of them. Don't pad with fluff, but don't leave ranking signals on the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short Description: The SEO Body Copy
&lt;/h2&gt;

&lt;p&gt;The short description (132 characters max) appears in search results and category listings. It's the second most important field for ranking.&lt;/p&gt;

&lt;p&gt;Pack it with semantically related terms — the words people would use to search for your extension:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before:
"Identify fonts on any webpage and copy CSS font-family syntax."

After:
"Click any element to identify the rendered font, copy CSS syntax, detect web fonts (Google/Adobe). Free font inspector for designers."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second version includes: font inspector, rendered font, CSS syntax, Google Fonts, Adobe Fonts, designers — all search-relevant terms without being spammy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Long Description: Depth Over Breadth
&lt;/h2&gt;

&lt;p&gt;The long description doesn't seem to rank for individual keywords the way a web page does, but it influences whether users install after they click through. High click-to-install conversion improves ranking indirectly.&lt;/p&gt;

&lt;p&gt;What converts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Lead with the problem, not the solution.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌  "DataPick is a Chrome extension that extracts web data."

✅  "Copying data from websites to Excel shouldn't require writing code.
    DataPick lets you click on any element to extract it — tables,
    prices, names, links — and export directly to Google Sheets."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Use bullet points with verbs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"View fonts" is weaker than "Click any element to instantly reveal its rendered font." Action-oriented bullets communicate value more clearly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Include the free/paid boundary explicitly.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Users are suspicious of extensions that don't clarify pricing. State it clearly: "Free: 3 scans/month. Pro ($9.99/month): unlimited scans + AI analysis."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. List what the extension does NOT do.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This sounds counterintuitive but it builds trust: "DataPick does not send your data to external servers. All extraction runs locally." Users who almost clicked away because of data concerns will install.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screenshot Quality Is a Ranking Factor
&lt;/h2&gt;

&lt;p&gt;Chrome Web Store A/B tests screenshot carousels for conversion. High-converting listings get better placement.&lt;/p&gt;

&lt;p&gt;My screenshots follow a formula:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Screenshot 1: The core action (click something → get result)&lt;/li&gt;
&lt;li&gt;Screenshot 2: The output or result state&lt;/li&gt;
&lt;li&gt;Screenshot 3: Settings or Pro features&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;1280×800 is the standard size. Use a real browser with realistic content, not placeholder text. Annotations (arrows, callouts) help but should be minimal.&lt;/p&gt;

&lt;p&gt;I've measured approximately 20-30% higher CTR for listings with annotated screenshots vs. plain browser screenshots.&lt;/p&gt;

&lt;h2&gt;
  
  
  Category Selection
&lt;/h2&gt;

&lt;p&gt;Most extensions fit multiple categories. The category affects which browse pages you appear on, which affects discovery separate from search.&lt;/p&gt;

&lt;p&gt;My rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If it's for developers: &lt;strong&gt;Developer Tools&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;If it's for productivity: &lt;strong&gt;Productivity&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Don't use &lt;strong&gt;Other&lt;/strong&gt; unless nothing fits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Developer Tools has less competition than Productivity for most queries, but smaller total audience. Pick based on where your target user is browsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review Velocity Matters More Than Review Count
&lt;/h2&gt;

&lt;p&gt;A fresh 4.8-star extension with 15 reviews can rank above a 4.9-star extension with 150 reviews if the 15 reviews came in the last 30 days. CWS appears to weight recency.&lt;/p&gt;

&lt;p&gt;This means: if your extension is growing, make sure your review request is well-timed and converts.&lt;/p&gt;

&lt;p&gt;I prompt for reviews after a "success moment" — after the user has successfully completed the core action for the third time. Not on first launch, not on a timer. After they've experienced value.&lt;/p&gt;

&lt;p&gt;The prompt text matters: "If DataPick saved you time today, a quick review helps other people find it." Specific, honest, no-pressure. I've measured about 12% conversion rate from prompt shown to review left.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Permission Warning Problem
&lt;/h2&gt;

&lt;p&gt;Extensions that request broad permissions show a warning during install: "This extension can read all your browser data." This is accurate but alarming phrasing.&lt;/p&gt;

&lt;p&gt;Two things help:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request only the permissions you actually need&lt;/li&gt;
&lt;li&gt;Explain permissions in your listing description: "DataPick requires access to all sites because you can use it on any website. We never collect or transmit your data."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Extensions with unexplained broad permissions have measurably lower install rates. The listing is where you explain, not the install dialog.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Doesn't Move the Needle
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Keyword stuffing in the description.&lt;/strong&gt; The algorithm seems to penalize this now, and it hurts conversion anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Promo images.&lt;/strong&gt; The 1400×560 marquee image is shown in promotional spots (featured sections), not in regular search. Unless you're getting featured, it doesn't matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Video demos.&lt;/strong&gt; I've tested with and without. No measurable install difference from search. Videos do help for extensions with complex UX that's hard to show in a screenshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracking What Works
&lt;/h2&gt;

&lt;p&gt;CWS provides an Insights dashboard with impressions, clicks, and installs broken down by traffic source (search, browse, referral). Use it. Track which source drives installs, then optimize for that source.&lt;/p&gt;

&lt;p&gt;For me, CWS search accounts for ~70% of installs. That's where the SEO work pays off.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I publish Chrome extensions at dev-tools-hub.xyz. These notes are based on 6 months of iteration across 18 extensions.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Chrome Web Store SEO: How I Optimized 17 Extensions for Search</title>
      <dc:creator>SHOTA</dc:creator>
      <pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/_350df62777eb55e1/chrome-web-store-seo-how-i-optimized-17-extensions-for-search-2maj</link>
      <guid>https://forem.com/_350df62777eb55e1/chrome-web-store-seo-how-i-optimized-17-extensions-for-search-2maj</guid>
      <description>&lt;h2&gt;
  
  
  Chrome Web Store Has a Search Engine — and Nobody Optimizes for It
&lt;/h2&gt;

&lt;p&gt;Most Chrome extension developers publish their extension and hope for the best. But the Chrome Web Store has its own search algorithm, and optimizing for it can dramatically increase your install rate.&lt;/p&gt;

&lt;p&gt;After publishing 17 extensions and experimenting with different approaches, here's everything I've learned about CWS SEO.&lt;/p&gt;

&lt;h2&gt;
  
  
  How CWS Search Works
&lt;/h2&gt;

&lt;p&gt;The Chrome Web Store search algorithm considers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Extension name&lt;/strong&gt; (highest weight)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short description&lt;/strong&gt; (the 132-character summary)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full description&lt;/strong&gt; (the detailed listing text)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Category selection&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;User ratings and review count&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weekly active users (WAU)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Install/uninstall ratio&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let me break down each factor with real examples from my extensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Factor 1: Extension Name — Keyword First
&lt;/h2&gt;

&lt;p&gt;The single biggest SEO lever is your extension name. CWS heavily weights the name field for search ranking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before optimization:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Procshot" → Ranked nowhere for "screenshot chrome extension"&lt;/li&gt;
&lt;li&gt;"ZenRead" → Invisible for "reader mode chrome"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;After optimization (keyword-first format):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Procshot — Automatic Procedure Capture &amp;amp; Step-by-Step Guide Maker"&lt;/li&gt;
&lt;li&gt;"ZenRead — Reader Mode &amp;amp; Read Aloud for Chrome"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt; Average search impression increase of &lt;strong&gt;340%&lt;/strong&gt; across 17 extensions after switching to keyword-first names.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Formula
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;[Brand] — [Primary Keyword] [Secondary Keyword] [Modifier]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DataPick — Web Data Extractor &amp;amp; Scraper for Chrome&lt;/li&gt;
&lt;li&gt;CookieJar — Cookie Editor, Manager &amp;amp; Privacy Analyzer&lt;/li&gt;
&lt;li&gt;FocusGuard — Website Blocker &amp;amp; Focus Timer&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Factor 2: Short Description (132 Characters)
&lt;/h2&gt;

&lt;p&gt;The short description appears in search results and listing cards. Every character matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rules:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lead with the primary benefit, not the feature&lt;/li&gt;
&lt;li&gt;Include your top 2-3 keywords naturally&lt;/li&gt;
&lt;li&gt;End with a differentiator&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Example for DataPick:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Extract tables, lists &amp;amp; text from any webpage. Export to CSV, Excel, JSON or Google Sheets. No coding required."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This hits: extract, tables, webpage, CSV, Excel, JSON, Google Sheets, no coding — all high-volume search terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Factor 3: Full Description
&lt;/h2&gt;

&lt;p&gt;The full description supports long-tail keywords. Structure it as:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Opening paragraph&lt;/strong&gt; — Problem statement with keywords&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature list&lt;/strong&gt; — Each feature is a keyword opportunity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use cases&lt;/strong&gt; — "Perfect for [persona] who need to [action]"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Technical details&lt;/strong&gt; — Manifest V3, permissions explanation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy statement&lt;/strong&gt; — Builds trust and conversion&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Localization Multiplier
&lt;/h3&gt;

&lt;p&gt;CWS supports localized listings. I localize into 8 languages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;English, Japanese, Chinese (Simplified), Korean, Spanish, French, German, Portuguese&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each localization creates a new surface for search in that language. My Japanese Font Finder extension gets 40% of installs from Japanese-language searches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Factor 4: Screenshots and Promo Images
&lt;/h2&gt;

&lt;p&gt;Screenshots don't directly affect search ranking, but they massively impact &lt;strong&gt;click-through rate&lt;/strong&gt; from search results to your listing page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First screenshot should show the extension in action (not a marketing slide)&lt;/li&gt;
&lt;li&gt;Include text overlays explaining what's happening&lt;/li&gt;
&lt;li&gt;Use consistent branding across all screenshots&lt;/li&gt;
&lt;li&gt;1280x800 resolution, clean and professional&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What doesn't work:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generic mockups&lt;/li&gt;
&lt;li&gt;Too much text, too little product&lt;/li&gt;
&lt;li&gt;Inconsistent styling between screenshots&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Factor 5: Ratings and Reviews
&lt;/h2&gt;

&lt;p&gt;CWS algorithm favors extensions with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Higher average rating (4.0+ is the threshold for good ranking)&lt;/li&gt;
&lt;li&gt;More total reviews (even 5-10 reviews make a difference for new extensions)&lt;/li&gt;
&lt;li&gt;Recent reviews (recency matters)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Win-Ask Review Strategy
&lt;/h3&gt;

&lt;p&gt;I implemented a "Win-Ask" review prompt in all my extensions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Trigger after a &lt;strong&gt;successful action&lt;/strong&gt; (not on install, not randomly)&lt;/li&gt;
&lt;li&gt;For Procshot: After capturing 5 procedures successfully&lt;/li&gt;
&lt;li&gt;For DataPick: After 10 successful data extractions&lt;/li&gt;
&lt;li&gt;Two buttons: "Rate on Chrome Web Store" and "Not now"&lt;/li&gt;
&lt;li&gt;"Not now" dismisses permanently (no nagging)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This approach respects users while still generating reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Factor 6: Weekly Active Users
&lt;/h2&gt;

&lt;p&gt;WAU is a ranking signal. Extensions with higher WAU rank better, creating a flywheel: better ranking → more installs → higher WAU → better ranking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to improve WAU:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reduce uninstall rate (better onboarding, deliver value fast)&lt;/li&gt;
&lt;li&gt;Cross-promote between your extensions&lt;/li&gt;
&lt;li&gt;Content marketing (Dev.to, Zenn, Reddit)&lt;/li&gt;
&lt;li&gt;Keep the extension lightweight (slow extensions get uninstalled)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My CWS SEO Checklist
&lt;/h2&gt;

&lt;p&gt;For every extension I publish, I run through this checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Keyword-first name format&lt;/li&gt;
&lt;li&gt;[ ] 132-char description with top keywords&lt;/li&gt;
&lt;li&gt;[ ] Full description with structured sections&lt;/li&gt;
&lt;li&gt;[ ] 5 screenshots with text overlays&lt;/li&gt;
&lt;li&gt;[ ] Small tile (440x280) with brand consistency&lt;/li&gt;
&lt;li&gt;[ ] 8-language localization&lt;/li&gt;
&lt;li&gt;[ ] Category selection optimized&lt;/li&gt;
&lt;li&gt;[ ] Win-Ask review prompt implemented&lt;/li&gt;
&lt;li&gt;[ ] Cross-promotion banner added&lt;/li&gt;
&lt;li&gt;[ ] GA4 tracking for install/update/feature_used events&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;After applying these optimizations across all 17 extensions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After (30 days)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total weekly installs&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;td&gt;380&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average search impressions&lt;/td&gt;
&lt;td&gt;2,400/week&lt;/td&gt;
&lt;td&gt;8,100/week&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average CTR from search&lt;/td&gt;
&lt;td&gt;2.1%&lt;/td&gt;
&lt;td&gt;3.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average rating&lt;/td&gt;
&lt;td&gt;4.2&lt;/td&gt;
&lt;td&gt;4.5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The biggest winner was DataPick, which went from 15 installs/week to 65 after the keyword-first name change alone.&lt;/p&gt;

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

&lt;p&gt;CWS SEO isn't complicated, but almost nobody does it. The extension name is 80% of the battle. Get that right, and everything else follows.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://dev-tools-hub.xyz/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=cws-seo" rel="noopener noreferrer"&gt;S-Hub&lt;/a&gt; — 17 Chrome extensions for developers and productivity enthusiasts.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Explore S-Hub Extensions
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://chromewebstore.google.com/detail/procshot" rel="noopener noreferrer"&gt;Procshot&lt;/a&gt; — Auto-capture browser steps&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://chromewebstore.google.com/detail/epoehadeccangbpjldlbkapnakndbpkf" rel="noopener noreferrer"&gt;DataPick&lt;/a&gt; — Extract data from any webpage&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://chromewebstore.google.com/detail/lhngfkchfepfjjdfhimconagoejemofg" rel="noopener noreferrer"&gt;CookieJar&lt;/a&gt; — Cookie editor and privacy analyzer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;See all extensions at &lt;a href="https://dev-tools-hub.xyz/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=cws-seo" rel="noopener noreferrer"&gt;dev-tools-hub.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>seo</category>
      <category>webdev</category>
      <category>marketing</category>
    </item>
    <item>
      <title>How I Cross-Promote 18 Chrome Extensions Without Annoying Users</title>
      <dc:creator>SHOTA</dc:creator>
      <pubDate>Thu, 30 Apr 2026 15:48:07 +0000</pubDate>
      <link>https://forem.com/_350df62777eb55e1/how-i-cross-promote-18-chrome-extensions-without-annoying-users-1oa3</link>
      <guid>https://forem.com/_350df62777eb55e1/how-i-cross-promote-18-chrome-extensions-without-annoying-users-1oa3</guid>
      <description>&lt;p&gt;I publish Chrome extensions as a solo developer. I currently have 18 live extensions across two Chrome Web Store accounts, with more in development.&lt;/p&gt;

&lt;p&gt;Each extension runs in isolation. A user who installs ReadMark has no reason to know that Japanese Font Finder exists — unless I tell them. This is the cross-promotion problem.&lt;/p&gt;

&lt;p&gt;Done badly, cross-promotion is spammy: "You might also like..." banners competing with the actual UI, persistent notifications pushing other products, or promotional modals that appear on first launch. These damage trust and hurt retention.&lt;/p&gt;

&lt;p&gt;Done well, cross-promotion is genuinely useful: surfacing a related tool at the moment a user is most likely to want it, in a way that doesn't interrupt their workflow.&lt;/p&gt;

&lt;p&gt;Here's what I've implemented across my extensions and what actually works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Cross-Promotion Lives
&lt;/h2&gt;

&lt;p&gt;The four legitimate places I've found:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The onboarding screen&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every new extension I build has a welcome screen that appears once on first install. This is the highest-engagement moment — the user has just installed, they're curious, and they have no workflow to interrupt.&lt;/p&gt;

&lt;p&gt;The bottom of the onboarding screen includes a "You might also like" section showing 2-3 related extensions from my portfolio. The slot is populated based on the category of the extension they just installed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Productivity extensions → show other productivity tools&lt;/li&gt;
&lt;li&gt;Developer tools → show developer tools&lt;/li&gt;
&lt;li&gt;Japanese-language tools → show other Japan-focused tools
&lt;/li&gt;
&lt;/ul&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;RELATED_BY_CATEGORY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ExtensionInfo&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;productivity&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ReadMark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cws&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://chromewebstore.google.com/...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FocusGuard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cws&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://chromewebstore.google.com/...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;developer-tools&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Japanese Font Finder&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cws&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DataBridge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cws&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&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="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conversion rate on this slot: ~8%. It's the best-performing placement I have, and it requires zero additional interruption — the user is already looking at an onboarding screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The settings/options page&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My extensions all have a settings page accessible via the popup or options page. At the bottom, a small "Other tools by the same developer" section.&lt;/p&gt;

&lt;p&gt;This placement has low visibility — most users never open settings — but high intent. Someone exploring settings is an engaged user. Conversion here is ~4%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Post-export or post-action moments&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Some extensions have natural completion moments. Procshot, for example, has a "Guide created" state after the user finishes recording a workflow. At this point, the task is done and the user has a second of cognitive slack.&lt;/p&gt;

&lt;p&gt;I show a single inline suggestion: "Looking for complementary tools? Try [X]."&lt;/p&gt;

&lt;p&gt;This feels contextually relevant rather than promotional. If someone just created a procedure guide, they might also like a tool that helps them organize notes or manage their workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The Chrome Web Store listing itself&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I include links to related extensions in the long description text of each CWS listing. The format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
Other tools you might find useful:
• ReadMark (save scroll position): [link]
• Procshot (create step-by-step guides): [link]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CWS listing descriptions don't support hyperlinks that are clickable, but the URLs are visible and some users do copy and search them. More importantly, these listings show up in Google Search, which occasionally drives cross-traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Don't Do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Push notifications&lt;/strong&gt;: I have &lt;code&gt;notifications&lt;/code&gt; permission on some extensions for functional purposes (session timers, etc.). I never use this for promotions. Push notifications are for urgent, functional information only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In-session banners&lt;/strong&gt;: No banners appear while the user is actively using the extension. The active state is for doing the thing, not for advertising other things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Frequency-based nudges&lt;/strong&gt;: No "You've been using this for 30 days — here are our other products" messages. I've seen this pattern from larger companies and I find it manipulative.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email capture for cross-promotion&lt;/strong&gt;: Chrome extensions don't have a natural email capture point, and I don't manufacture one. My extensions work without accounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring It
&lt;/h2&gt;

&lt;p&gt;I track cross-promotion clicks with GA4 events:&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;function&lt;/span&gt; &lt;span class="nf"&gt;trackCrossPromoClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;targetExtension&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cross_promo_click&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;source_location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// 'onboarding' | 'settings' | 'post_action'&lt;/span&gt;
    &lt;span class="na"&gt;target_extension&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;targetExtension&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;The conversion from click to install is hard to measure directly — I can see the click in GA4 but I can't easily track what happens in the CWS after the user navigates there. I proxy this with CWS install trends: when I add a cross-promotion slot pointing to Extension X, does Extension X's weekly installs increase?&lt;/p&gt;

&lt;p&gt;Over 12 weeks, the answer has been yes, modestly — about a 15-20% lift in weekly installs for extensions that are actively promoted from related extensions. Not explosive growth, but it compounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Principle
&lt;/h2&gt;

&lt;p&gt;Cross-promotion works when it answers the question "what else can help me with what I'm already trying to do?" It fails when it answers "what else can I sell this user?"&lt;/p&gt;

&lt;p&gt;The onboarding screen converts at 8% not because users are gullible but because the recommended extensions are actually useful to someone who just installed a related tool. The relevance makes the difference.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about solo Chrome extension development at dev-tools-hub.xyz.&lt;/em&gt;&lt;/p&gt;

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