<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Projekta2</title>
    <description>The latest articles on DEV Community by Projekta2 (@projekta2).</description>
    <link>https://dev.to/projekta2</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3979047%2F0c47a53b-2c78-477a-9ab8-eac0b13d0f1f.jpeg</url>
      <title>DEV Community: Projekta2</title>
      <link>https://dev.to/projekta2</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/projekta2"/>
    <language>en</language>
    <item>
      <title>I built an AI Chrome extension with zero backend cost — here's the exact architecture</title>
      <dc:creator>Projekta2</dc:creator>
      <pubDate>Sun, 28 Jun 2026 08:41:02 +0000</pubDate>
      <link>https://dev.to/projekta2/i-built-an-ai-chrome-extension-with-zero-backend-cost-heres-the-exact-architecture-43j7</link>
      <guid>https://dev.to/projekta2/i-built-an-ai-chrome-extension-with-zero-backend-cost-heres-the-exact-architecture-43j7</guid>
      <description>&lt;p&gt;You want to add AI to your Chrome extension.&lt;/p&gt;

&lt;p&gt;The obvious path: spin up a Node.js server, hold a master API key, charge users monthly, eat the AI cost. That's what everyone does.&lt;/p&gt;

&lt;p&gt;I didn't do that. I built three Chrome extensions with AI features — PR summarization, risk scoring, draft review generation — and my monthly infrastructure bill is $0. No server. No backend. No API key to protect.&lt;/p&gt;

&lt;p&gt;Here's the exact architecture, the real trade-offs, and the specific places where this approach breaks down so you don't find out the hard way.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with the "standard" approach
&lt;/h2&gt;

&lt;p&gt;Most AI-powered extensions work like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User → Extension → Your server → AI provider → Your server → Extension → User
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your server holds a master API key. Users pay you. You pay the AI provider out of that margin.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;You're a proxy business now.&lt;/strong&gt; You're paying OpenAI $X, charging users $Y, and the difference is your margin. But you're also responsible for rate limiting, uptime, abuse prevention, and GDPR compliance for every request that touches your server.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Private code goes through your infra.&lt;/strong&gt; For a developer tool that reads GitHub diffs, this is the question users ask first: &lt;em&gt;"is my code going to your server?"&lt;/em&gt; With a hosted backend, the honest answer is yes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;You're competing on price against companies with VC money.&lt;/strong&gt; CodeRabbit, GitHub Copilot, Linear, and a dozen others are running hosted AI with economies of scale you can't match as a solo developer.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's a different architecture. It's not new — it's called BYOK (Bring Your Own Key), and it shifts the AI provider relationship from you to the user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User → Extension → AI provider (user's own key)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No server in the middle. No margin math. No &lt;em&gt;"is my code safe"&lt;/em&gt; question.&lt;/p&gt;




&lt;h2&gt;
  
  
  How BYOK works in a Chrome extension
&lt;/h2&gt;

&lt;p&gt;The core mechanic is simple: instead of your extension calling your server, it calls the AI provider directly from the browser using the user's own API key.&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="c1"&gt;// The user pastes their API key during onboarding&lt;/span&gt;
&lt;span class="c1"&gt;// You store it locally — never send it anywhere else&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;aiApiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userProvidedKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;aiProvider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;groq&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// or 'openai', 'mistral', 'ollama'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Every AI call uses their key, from their browser&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;callAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&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;aiApiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aiProvider&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;aiApiKey&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;aiProvider&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;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getEndpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aiProvider&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="nx"&gt;endpoint&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;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;aiApiKey&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="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="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="nf"&gt;getModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aiProvider&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prompt&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;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="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;The API key lives in &lt;code&gt;chrome.storage.local&lt;/code&gt;. It never leaves the browser except to go directly to the AI provider. Your extension never sees it again after the user pastes it in.&lt;/p&gt;




&lt;h2&gt;
  
  
  The manifest.json permissions you actually need
&lt;/h2&gt;

&lt;p&gt;For direct API calls from a Chrome extension, declare host permissions for each provider you support:&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;"manifest_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&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="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"storage"&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;"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.openai.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://api.groq.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://api.mistral.ai/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:*/*"&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;The &lt;code&gt;localhost&lt;/code&gt; entry covers Ollama — for users who want a fully local model with zero API costs.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; In MV3, host permissions are scrutinized during review. Be specific. Don't use &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; when you can name the exact domains. I've been through CWS review twice with this manifest — being explicit helps.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Supporting multiple providers without a mess
&lt;/h2&gt;

&lt;p&gt;All four major providers use the OpenAI-compatible &lt;code&gt;/v1/chat/completions&lt;/code&gt; format. One implementation, four providers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AI_PROVIDERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;groq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&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.groq.com/openai/v1/chat/completions&lt;/span&gt;&lt;span class="dl"&gt;'&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;llama-3.3-70b-versatile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;maxTokens&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="na"&gt;supportsStreaming&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="na"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&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.openai.com/v1/chat/completions&lt;/span&gt;&lt;span class="dl"&gt;'&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;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;maxTokens&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="na"&gt;supportsStreaming&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="na"&gt;mistral&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&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.mistral.ai/v1/chat/completions&lt;/span&gt;&lt;span class="dl"&gt;'&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;mistral-small-latest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;maxTokens&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="na"&gt;supportsStreaming&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="na"&gt;ollama&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&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://localhost:11434/v1/chat/completions&lt;/span&gt;&lt;span class="dl"&gt;'&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;llama3.2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;maxTokens&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="na"&gt;supportsStreaming&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="p"&gt;};&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;getProviderConfig&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;aiProvider&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;aiProvider&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;AI_PROVIDERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aiProvider&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;AI_PROVIDERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;groq&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;Store the model name here, not hardcoded in your fetch calls. When Groq deprecated an older Llama version, I pushed one config update and every user was on the new model automatically — no user action required.&lt;/p&gt;




&lt;h2&gt;
  
  
  The onboarding friction problem — and how to reduce it
&lt;/h2&gt;

&lt;p&gt;Here's the real cost of BYOK: &lt;strong&gt;users have to get an API key before they can use your AI features.&lt;/strong&gt; Some users bounce at this step.&lt;/p&gt;

&lt;p&gt;What actually reduces friction:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Lead with Groq.&lt;/strong&gt; Groq's free tier covers &lt;a href="https://console.groq.com/settings/limits" rel="noopener noreferrer"&gt;~14,400 requests per day&lt;/a&gt; for smaller models. For most individual developers, it's genuinely free. This changes the conversation from &lt;em&gt;"go pay for an API key"&lt;/em&gt; to &lt;em&gt;"go get a free API key in 2 minutes."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Give the exact steps, not a vague instruction:&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;Step 1: Go to console.groq.com/keys
Step 2: Click "Create API key"
Step 3: Paste the key here → [input]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines. No ambiguity. I track where users drop off in onboarding — the step with the most abandonment is always the one where I said "get your API key" without saying exactly where.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Make core features work without AI.&lt;/strong&gt; If every feature is gated behind BYOK setup, the first session is a setup session — and many users don't return for a second. In PR Focus, multi-account GitHub, PR sorting, CSV export, and stale notifications all work without any API key. The AI features are additive.&lt;/p&gt;




&lt;h2&gt;
  
  
  The MV3 service worker problem with streaming
&lt;/h2&gt;

&lt;p&gt;If you want to stream AI responses token by token, you hit an MV3 constraint: service workers handle the API calls, but streaming requires a long-lived connection, and service workers can be terminated mid-stream.&lt;/p&gt;

&lt;p&gt;The pattern that works — service worker handles the fetch, sends tokens to the popup via messages:&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="c1"&gt;// Service worker — handles the streaming fetch&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;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sendResponse&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;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&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;STREAM_AI&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;streamAIResponse&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;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sender&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;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Keep the message channel open&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&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;streamAIResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&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;getProviderConfig&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;aiApiKey&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;aiApiKey&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;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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoint&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;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;aiApiKey&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="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="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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="na"&gt;stream&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&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="nf"&gt;getReader&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;decoder&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;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;while &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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;done&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&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;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&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;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;line&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;data: &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;lines&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&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;6&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="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[DONE]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&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;parse&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&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="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;content&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="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;sendMessage&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;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;AI_TOKEN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Skip malformed chunks — they happen&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;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;sendMessage&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;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;AI_DONE&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 fetch keeps the service worker alive for the duration of the stream. Tokens go to the popup via messages. The popup accumulates them and renders progressively.&lt;/p&gt;




&lt;h2&gt;
  
  
  Specific error handling — this saves you support tickets
&lt;/h2&gt;

&lt;p&gt;The most common support category with BYOK: users with wrong or misconfigured keys. Generic "AI error" messages generate follow-up tickets. Status-code-specific messages don't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateApiKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;provider&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;AI_PROVIDERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;provider&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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoint&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;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&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="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="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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test&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;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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&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;valid&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;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;Invalid API key — check you copied it completely, no trailing spaces.&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&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;valid&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;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;Rate limit hit — your key is valid but you&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;ve hit the free tier ceiling.&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&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;valid&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;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;Permission denied — this key may not have access to this model tier.&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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;valid&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;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Provider returned &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — try again in a moment.`&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;valid&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="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;valid&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;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;Network error — check your internet connection or try a different provider.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Token costs in practice — real numbers
&lt;/h2&gt;

&lt;p&gt;A typical PR summary in PR Focus: ~800 tokens input (diff context + system prompt), ~150 tokens output. ~950 tokens per PR.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Cost per PR&lt;/th&gt;
&lt;th&gt;100 PRs/day&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Groq (Llama 3.3 70B)&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI GPT-4o-mini&lt;/td&gt;
&lt;td&gt;Paid&lt;/td&gt;
&lt;td&gt;~$0.0001&lt;/td&gt;
&lt;td&gt;~$0.01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mistral Small&lt;/td&gt;
&lt;td&gt;Paid&lt;/td&gt;
&lt;td&gt;~$0.00008&lt;/td&gt;
&lt;td&gt;~$0.008&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ollama (local)&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cost argument for BYOK isn't just privacy — it's math. A hosted model charging $10/month makes pennies after AI costs and infrastructure. Users with their own Groq key pay nothing for individual use. That's a value proposition you can't match with a hosted backend.&lt;/p&gt;




&lt;h2&gt;
  
  
  What breaks — be honest about it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Corporate users behind strict proxies.&lt;/strong&gt; Some enterprise environments block direct browser-to-external-API calls. You can't fix this. Be upfront about it, and point to Ollama as the local workaround.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ollama requires a separate install.&lt;/strong&gt; It's not "just paste a key" — it's "install Ollama, pull a model, run it locally, then configure the extension." Worth supporting for privacy-first users, but don't pitch it as the simple path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can't cache responses.&lt;/strong&gt; Each user's key means each user pays for their own calls. No cross-user caching. For most use cases this doesn't matter, but if you're building something where 1000 users asking the same question is likely, hosted with caching will be cheaper for them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Is BYOK right for your extension?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Yes, if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your users are developers or technical enough that "API key" isn't a foreign concept&lt;/li&gt;
&lt;li&gt;Privacy is a genuine selling point (code review, writing assistance, anything involving private data)&lt;/li&gt;
&lt;li&gt;You're solo and don't want to operate infrastructure&lt;/li&gt;
&lt;li&gt;You want a free tier without eating AI costs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;No, if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your audience is non-technical and &lt;em&gt;"API key"&lt;/em&gt; will lose them before they get to your value&lt;/li&gt;
&lt;li&gt;You need to control which model is used for consistency or quality reasons&lt;/li&gt;
&lt;li&gt;You want platform-level caching, rate limiting, or abuse prevention&lt;/li&gt;
&lt;li&gt;You're fine with a subscription model and want the simplicity of a managed service&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The architecture in one diagram
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chrome.storage.local
  ├── aiApiKey      ← user's own, never leaves browser except to provider
  └── aiProvider    ← 'groq' | 'openai' | 'mistral' | 'ollama'

Popup / content script
  └── message → service worker: { type: 'RUN_AI', prompt }

Service worker
  ├── reads key + provider from storage
  ├── calls provider API directly (fetch)
  └── streams tokens → popup via chrome.runtime.sendMessage

Infrastructure cost: $0
Monthly AI bill: $0
Trust question ("does my code go to your server?"): No.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  See it running in production
&lt;/h2&gt;

&lt;p&gt;Everything in this article is running in &lt;strong&gt;&lt;a href="https://chromewebstore.google.com/detail/pr-focus-ai-pro/ememaiabefeojkccjclglcmbjmdpnaoe" rel="noopener noreferrer"&gt;PR Focus Pro&lt;/a&gt;&lt;/strong&gt; — a Chrome extension that triages GitHub pull requests with AI summaries, hybrid risk scoring (0–100), and one-click draft reviews. Free to install; AI features activate with your own API key.&lt;/p&gt;

&lt;p&gt;The full engineering decision log behind this architecture — including the options I rejected, what it cost in user friction, and whether I'd choose it again — is &lt;a href="https://github.com/projekta2/build-logs/blob/main/build-logs/007-byok-chrome-extension-architecture.md" rel="noopener noreferrer"&gt;Build Log #007&lt;/a&gt; in my public Build Logs repo.&lt;/p&gt;

&lt;p&gt;If you're building something similar and want a second pair of eyes on your implementation, the &lt;a href="https://github.com/projekta2/build-logs/issues/1" rel="noopener noreferrer"&gt;Summer Review Swap&lt;/a&gt; is open — there's a PR waiting for a reviewer right now if you want to jump straight in.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's your approach to AI in browser extensions? Running your own backend, BYOK, or something else entirely? Particularly curious whether anyone has found a cleaner solution to the streaming + service worker termination problem — drop it in the comments.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Links in this article:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://chromewebstore.google.com/detail/pr-focus-ai-pro/ememaiabefeojkccjclglcmbjmdpnaoe" rel="noopener noreferrer"&gt;PR Focus Pro on Chrome Web Store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://projekta2.github.io/pr-focus-landing/pr-focus-demo.html" rel="noopener noreferrer"&gt;Interactive demo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/projekta2/build-logs/blob/main/build-logs/007-byok-chrome-extension-architecture.md" rel="noopener noreferrer"&gt;Build Log #007 — full engineering decision log&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/projekta2/build-logs/blob/main/build-logs/002-why-pr-focus-is-byok.md" rel="noopener noreferrer"&gt;Build Log #002 — why BYOK vs hosted backend&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/projekta2/build-logs/issues/1" rel="noopener noreferrer"&gt;Summer Review Swap — open through July&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://console.groq.com/settings/limits" rel="noopener noreferrer"&gt;Groq free tier limits&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>chrome</category>
      <category>javascript</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>GitHub's new PR limits are good. They solve the wrong problem.</title>
      <dc:creator>Projekta2</dc:creator>
      <pubDate>Fri, 26 Jun 2026 22:18:55 +0000</pubDate>
      <link>https://dev.to/projekta2/githubs-new-pr-limits-are-good-they-solve-the-wrong-problem-1a7b</link>
      <guid>https://dev.to/projekta2/githubs-new-pr-limits-are-good-they-solve-the-wrong-problem-1a7b</guid>
      <description>&lt;p&gt;GitHub shipped something genuinely useful last week: maintainers can now cap how many concurrent open PRs an outside contributor can have at once. AutoGPT called it life-changing. Homebrew said it was the fix they'd been waiting for.&lt;/p&gt;

&lt;p&gt;They're right. It's a good feature.&lt;/p&gt;

&lt;p&gt;It also solves the &lt;strong&gt;intake&lt;/strong&gt; problem. Not the &lt;strong&gt;triage&lt;/strong&gt; problem. And those are two completely different things.&lt;/p&gt;




&lt;h2&gt;
  
  
  What just shipped, and why it matters
&lt;/h2&gt;

&lt;p&gt;For open source maintainers dealing with AI-generated PR floods, the new cap is real relief. GitHub's own numbers tell the story: merged pull requests went from 25 million per month in January 2023 to &lt;strong&gt;90 million per month in March 2026&lt;/strong&gt; — a 3.6x increase in three years, almost entirely driven by AI-assisted code generation.&lt;/p&gt;

&lt;p&gt;One maintainer in the &lt;a href="https://github.com/orgs/community/discussions/185387" rel="noopener noreferrer"&gt;GitHub community discussion&lt;/a&gt; put it bluntly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"1 out of 10 PRs created with AI is legitimate and meets the standards required to open that PR."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So yes — reducing the volume of incoming noise is valuable. If contributors can only have 2 or 3 open PRs at a time, they have to be selective. The junk-to-signal ratio in the queue goes down.&lt;/p&gt;

&lt;p&gt;But here's what the PR limit doesn't change: &lt;strong&gt;once a PR is open, you still have to figure out which one to look at first.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem nobody is talking about
&lt;/h2&gt;

&lt;p&gt;There's a different PR crisis happening in parallel, and it affects teams that aren't drowning in AI slop at all.&lt;/p&gt;

&lt;p&gt;Faros AI analyzed teams that adopted AI coding tools and found something uncomfortable: they were generating &lt;strong&gt;98% more pull requests&lt;/strong&gt; while experiencing a &lt;strong&gt;91% increase in PR review time&lt;/strong&gt;. The perception gap was 39 points — developers estimated they were 20% faster with AI, while actually performing &lt;strong&gt;19% slower&lt;/strong&gt; when you measured end-to-end cycle time.&lt;/p&gt;

&lt;p&gt;The bottleneck wasn't writing code. It was reviewing it.&lt;/p&gt;

&lt;p&gt;And the PR limit does nothing here. If your team of 4 engineers is generating twice as many PRs with Copilot, capping outside contributors to 2 open PRs doesn't help you decide whether to review the 400-line refactor or the 12-line bug fix first.&lt;/p&gt;

&lt;p&gt;That decision — &lt;strong&gt;which PR to look at right now&lt;/strong&gt; — is where most developer time actually goes. Silently, invisibly, every single day.&lt;/p&gt;




&lt;h2&gt;
  
  
  What triage actually looks like without tooling
&lt;/h2&gt;

&lt;p&gt;Here's the honest version of what happens when a developer opens GitHub and sees 23 open PRs across 4 repos:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;They scan titles. Titles are unreliable. "Fix bug in auth module" could be a one-line typo fix or a complete rewrite.&lt;/li&gt;
&lt;li&gt;They open the ones that look familiar or important. This is vibes-based prioritization.&lt;/li&gt;
&lt;li&gt;They get halfway through a complex PR, realize it depends on another PR that's blocked, and context-switch.&lt;/li&gt;
&lt;li&gt;They come back later and start again from the list.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This process takes time not because the developer is inefficient, but because &lt;strong&gt;the information needed to make a good prioritization decision is buried inside each PR&lt;/strong&gt; — and you can't see it without opening it.&lt;/p&gt;

&lt;p&gt;The signals that actually matter are almost never visible from the default list view:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is CI passing &lt;em&gt;right now&lt;/em&gt;, or did it pass when the PR was opened and then fail?&lt;/li&gt;
&lt;li&gt;Has the author been responding to comments in the last 48 hours, or is this effectively abandoned?&lt;/li&gt;
&lt;li&gt;Does this PR touch files that overlap with another open PR — making them dependent even if they don't look it?&lt;/li&gt;
&lt;li&gt;Is the diff size proportional to what the description claims?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these require AI to compute. They're deterministic signals. But they're invisible from a flat list.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it looks like when you surface these signals
&lt;/h2&gt;

&lt;p&gt;This is what a prioritized PR inbox looks like when those signals are made visible before you open anything:&lt;/p&gt;

&lt;p&gt;![PR Focus Pro inbox — PRs prioritized by hybrid risk score. Top PR: Risk 87, CI failing, 9 days old. Second PR: Risk 48, CI pending, 4 days old. Each card shows an AI summary of what the PR actually does.]&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fic46kuthfb8va5dkxhr3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fic46kuthfb8va5dkxhr3.png" alt=" " width="600" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;PR Focus Pro — the first PR (Risk 87) has failing CI and touches auth. It needs attention today. The second (Risk 48) has pending CI and is a cache layer change. It can wait. You knew both of these things before opening either.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The risk score combines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CI status at triage time (not just at merge)&lt;/li&gt;
&lt;li&gt;File types touched (auth, config, DB migrations score higher)&lt;/li&gt;
&lt;li&gt;PR age vs. author activity&lt;/li&gt;
&lt;li&gt;Scope-to-size ratio (a 400-line "minor fix" is a flag)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The AI layer adds plain-language summaries — what the PR actually does, independent of its description. But the &lt;strong&gt;priority order comes from deterministic signals&lt;/strong&gt;, not from AI. That distinction matters for reliability: you need to trust your triage tool every single day.&lt;/p&gt;




&lt;h2&gt;
  
  
  The two-layer problem
&lt;/h2&gt;

&lt;p&gt;What's actually happening in 2026 is a two-layer problem, and GitHub is solving layer one:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1 — Intake:&lt;/strong&gt; Too many low-quality PRs entering the queue. GitHub's new limits, configurable permissions, and PR deletion features address this. ✅ Good.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2 — Triage:&lt;/strong&gt; Once PRs are in the queue, developers still need to decide what to review first, how much attention each PR deserves, and which ones are likely to waste time even if they look legitimate. ❌ Mostly unsolved.&lt;/p&gt;

&lt;p&gt;The reason layer 2 gets less attention is that it's less dramatic. A flood of AI-generated slop from a bot account is visible and infuriating. A developer spending 90 minutes doing implicit mental triage before starting their first real review is invisible — it just looks like "slow" work.&lt;/p&gt;

&lt;p&gt;But the cumulative cost is enormous. If 4 developers spend 45 minutes doing mental triage before every review session, that's 3 hours a day of unrecorded productivity loss. Across a week, it's a full developer-day gone.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where AI actually helps (and where it doesn't)
&lt;/h2&gt;

&lt;p&gt;There's a temptation to throw AI at the triage problem entirely. GitHub is exploring this — their community discussion mentions "potentially leveraging AI to evaluate contributions against project guidelines."&lt;/p&gt;

&lt;p&gt;AI is genuinely useful for &lt;strong&gt;one specific thing&lt;/strong&gt; in PR triage: generating a plain-language summary of what a PR actually does, independent of what its description claims. That's valuable context before you open the diff.&lt;/p&gt;

&lt;p&gt;Where pure AI triage falls short:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consistency&lt;/strong&gt; — if your tool gives a different risk score for the same PR on different days, you stop trusting it. Deterministic signals are reproducible. LLM scores aren't always.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speed&lt;/strong&gt; — calling an LLM for every PR in a 20+ queue adds real latency. Deterministic scoring is instant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reliability on your specific codebase&lt;/strong&gt; — LLMs don't have reliable intuition about which files in &lt;em&gt;your&lt;/em&gt; repo are critical paths. A config file in one codebase is noise; in another it's the most dangerous thing to touch.&lt;/p&gt;

&lt;p&gt;The architecture that works: &lt;strong&gt;deterministic signals set the priority order, AI provides context for why&lt;/strong&gt;. Not the other way around.&lt;/p&gt;




&lt;h2&gt;
  
  
  GitHub's 2026 roadmap — and the gap
&lt;/h2&gt;

&lt;p&gt;GitHub's current direction covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Intake controls (PR caps, collaborator restrictions)&lt;/li&gt;
&lt;li&gt;✅ Moderation (PR deletion, low-quality flags)&lt;/li&gt;
&lt;li&gt;🔄 AI transparency (attribution for AI-assisted PRs — in exploration)&lt;/li&gt;
&lt;li&gt;❌ Triage tooling for the PRs that &lt;em&gt;do&lt;/em&gt; deserve review&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the gap. It's too workflow-specific and too team-context-dependent for GitHub to solve at the platform level. It has to be solved at the tooling layer — where the signals are accessible via API, the computation is cheap, and the presentation lives in the context where developers already work.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters now
&lt;/h2&gt;

&lt;p&gt;The PR flood problem gets attention because it's visible. Bots submitting 30 PRs to your repo is dramatic. But the quieter problem — good developers spending significant chunks of their day doing invisible mental triage — is costing engineering teams more in aggregate.&lt;/p&gt;

&lt;p&gt;GitHub's new PR limits are a genuinely good first step. They reduce the noise coming in. But they don't help with the queue that already exists, and they don't help teams whose PR volume is growing because their &lt;em&gt;own&lt;/em&gt; developers are more productive, not because of external spam.&lt;/p&gt;

&lt;p&gt;Layer 1 is getting solved. Layer 2 is still mostly manual, mostly invisible, and mostly expensive.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I've been thinking about this long enough that I built something to solve it: &lt;a href="https://chromewebstore.google.com/detail/pr-focus-ai-pro/ememaiabefeojkccjclglcmbjmdpnaoe" rel="noopener noreferrer"&gt;PR Focus Pro&lt;/a&gt; uses exactly the hybrid approach described here — deterministic signals for the priority score, optional AI summaries for context, 100% local (BYOK — OpenAI, Groq, Mistral, or Ollama). Freemium, $9.50 one-time for Pro, no subscription. But this piece is about the problem, not the product — the triage gap exists regardless of what tool you use to close it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Maintaining repos and found other signals that help? I'd genuinely like to hear them — drop a comment or find me on &lt;a href="https://github.com/projekta2" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>github</category>
      <category>codereview</category>
      <category>productivity</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Migrating to Manifest V3: what actually broke, what we saved, and what we gained</title>
      <dc:creator>Projekta2</dc:creator>
      <pubDate>Wed, 24 Jun 2026 10:40:19 +0000</pubDate>
      <link>https://dev.to/projekta2/migrating-to-manifest-v3-what-actually-broke-what-we-saved-and-what-we-gained-ed1</link>
      <guid>https://dev.to/projekta2/migrating-to-manifest-v3-what-actually-broke-what-we-saved-and-what-we-gained-ed1</guid>
      <description>&lt;p&gt;I built three Chrome extensions natively on Manifest V3. Not ported — built from scratch knowing MV3 was the target. Here's what that actually means in practice.&lt;/p&gt;

&lt;p&gt;This isn't a "MV3 is great/terrible" opinion piece. It's a list of concrete decisions I had to make and bugs I had to debug, with the code that came out the other side.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem MV3 solves (and why it matters to you even if you don't care about Chrome's reasons)
&lt;/h2&gt;

&lt;p&gt;MV2 extensions could run persistent background scripts that had unrestricted access to network requests. This is how ad blockers worked — and it's also how malicious extensions worked. Chrome's argument for MV3 is that restricting background script behavior makes extensions less dangerous.&lt;/p&gt;

&lt;p&gt;Whether you agree with that tradeoff or not, it's done. MV2 extensions are gone from the Web Store. If you're building extensions now, you're building on MV3.&lt;/p&gt;

&lt;p&gt;The practical consequence: &lt;strong&gt;your background logic no longer runs in a persistent process&lt;/strong&gt;. It runs in a Service Worker that Chrome can terminate at any moment, then restart when it needs to handle an event.&lt;/p&gt;

&lt;p&gt;This changes everything about how you architect state.&lt;/p&gt;




&lt;h2&gt;
  
  
  Change #1: Service Workers kill your global state
&lt;/h2&gt;

&lt;p&gt;In MV2, you had &lt;code&gt;background.js&lt;/code&gt;. It ran. It had variables. Those variables were always there.&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="c1"&gt;// MV2 background.js — this just worked&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;userSettings&lt;/span&gt; &lt;span class="o"&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;tabTimers&lt;/span&gt; &lt;span class="o"&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;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onActivated&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;activeInfo&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="c1"&gt;// tabTimers is always available here&lt;/span&gt;
  &lt;span class="nf"&gt;startTimer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;activeInfo&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In MV3, the Service Worker can die between events. That &lt;code&gt;tabTimers&lt;/code&gt; object you built up over the last hour? Gone if Chrome decided to reclaim memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; &lt;code&gt;chrome.storage&lt;/code&gt; is your only reliable persistence layer.&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="c1"&gt;// MV3 — everything that needs to survive goes to storage&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;startTimer&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tabTimers&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="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;tabTimers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;tabTimers&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tabTimers&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="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="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="nx"&gt;tabTimers&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 cost: async everywhere. &lt;code&gt;chrome.storage&lt;/code&gt; operations return Promises. If you're used to synchronous state access, this requires rethinking your code's structure, not just a search-and-replace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hidden benefit:&lt;/strong&gt; This discipline makes your extension more resilient. If the browser crashes and restarts, your state survives. You get crash recovery for free.&lt;/p&gt;




&lt;h2&gt;
  
  
  Change #2: &lt;code&gt;setInterval&lt;/code&gt; in background is unreliable — use Alarms
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;setInterval&lt;/code&gt; in a MV2 background page: works perfectly, runs every N seconds indefinitely.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setInterval&lt;/code&gt; in a MV3 Service Worker: runs until Chrome decides to kill the SW. Could be 30 seconds. Could be 5 minutes. Not predictable.&lt;/p&gt;

&lt;p&gt;For TabCost Pro, I need to update the cost counter periodically. This is exactly the problem &lt;code&gt;chrome.alarms&lt;/code&gt; solves:&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="c1"&gt;// Register once — survives SW restarts&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;onInstalled&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;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;costTick&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;periodInMinutes&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="c1"&gt;// Handle in the SW — Chrome wakes the SW for this&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="k"&gt;async &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;costTick&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateAllTabCosts&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;&lt;strong&gt;Critical limitation:&lt;/strong&gt; The minimum alarm period in MV3 is &lt;strong&gt;1 minute&lt;/strong&gt;. If you need sub-minute updates (like a cost counter ticking in near-real-time), you need a different approach for when the popup is open.&lt;/p&gt;

&lt;p&gt;My solution for TabCost: the popup runs its own &lt;code&gt;setInterval&lt;/code&gt; when it's visible (popups have their own lifecycle, they can use intervals while open), and the alarm handles persistence when the popup is closed.&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="c1"&gt;// In popup.js — runs only while popup is open&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;localInterval&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;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;DOMContentLoaded&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;localInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updateDisplay&lt;/span&gt;&lt;span class="p"&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;// Updates every second&lt;/span&gt;
  &lt;span class="nf"&gt;loadFromStorage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Initialize from persisted state&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localInterval&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;persistCurrentState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Save before popup closes&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Change #3: IndexedDB for large datasets
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;chrome.storage.local&lt;/code&gt; has a 10MB limit (configurable with &lt;code&gt;unlimitedStorage&lt;/code&gt; permission, but that requires justification to the Chrome Web Store). For PR Focus, storing summaries and risk scores for 100+ PRs can easily exceed that.&lt;/p&gt;

&lt;p&gt;IndexedDB is the answer. It can handle gigabytes, it's transactional, and it's accessible from both the popup and content scripts.&lt;/p&gt;

&lt;p&gt;The catch: IndexedDB access from Service Workers was historically unreliable (Chrome had bugs here until around Chrome 102). Check your minimum Chrome version and test explicitly.&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="c1"&gt;// db.js — shared database module&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DB_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;PRFocusDB&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;DB_VERSION&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;openDB&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="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="nx"&gt;reject&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;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;indexedDB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DB_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DB_VERSION&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;onupgradeneeded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&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="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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;objectStoreNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prData&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;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prData&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;keyPath&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&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;repo&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;repo&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;unique&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;riskScore&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;riskScore&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;unique&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="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;onsuccess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Change #4: No &lt;code&gt;eval()&lt;/code&gt;, no remote scripts, stricter CSP
&lt;/h2&gt;

&lt;p&gt;MV3 prohibits executing dynamically evaluated code from remote sources. If your extension was loading a script from a CDN and running it, that's over.&lt;/p&gt;

&lt;p&gt;The practical impact on my code: I had some HTML generation using template literals assigned to &lt;code&gt;innerHTML&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ This works but is bad practice in MV3 context&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="pr-card" data-id="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;
    &amp;lt;h3&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pr&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="s2"&gt;&amp;lt;/h3&amp;gt;
    &amp;lt;span class="score"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;
  &amp;lt;/div&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 problem isn't &lt;code&gt;innerHTML&lt;/code&gt; itself (it's not &lt;code&gt;eval&lt;/code&gt;), but if &lt;code&gt;pr.title&lt;/code&gt; contains HTML, you've introduced an XSS vector. MV3's stricter CSP makes this harder to exploit, but the right move is to stop using it entirely:&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="c1"&gt;// ✅ DOM API — safe, explicit, CSP-compliant&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createPRCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pr&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;card&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;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pr-card&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&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;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h3&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pr&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="c1"&gt;// textContent never executes HTML&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;score&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;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;span&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;score&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;score&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="nx"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&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="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&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;card&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;More verbose, but explicit and safe.&lt;/p&gt;




&lt;h2&gt;
  
  
  Change #5: Message passing between SW and popup requires explicit design
&lt;/h2&gt;

&lt;p&gt;In MV2, background scripts and popups shared a browsing context in some ways that made communication feel more natural. In MV3, the SW and popup are completely separate contexts. Communication requires &lt;code&gt;chrome.runtime.sendMessage&lt;/code&gt; / &lt;code&gt;chrome.runtime.onMessage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The bug I spent two days on: I assumed the Service Worker could stream progress updates to the popup while processing PRs. It can't — the SW sends a message, the popup receives it, but if the popup isn't actively listening at that exact moment, the message is lost.&lt;/p&gt;

&lt;p&gt;The solution for PR Focus: use &lt;code&gt;chrome.runtime.connect&lt;/code&gt; for persistent connections when you need streaming:&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="c1"&gt;// In the 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;prAnalysis&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="nx"&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;progress&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;updateProgressBar&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="nx"&gt;percent&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="nx"&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;complete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;displayResults&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="nx"&gt;data&lt;/span&gt;&lt;span class="p"&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;start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;repos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;selectedRepos&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// In the 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;prAnalysis&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;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="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="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="nx"&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;start&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;repo&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;analyzeRepo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;progress&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;progress&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;percent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;progress&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;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;complete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;results&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;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What MV3 gives you that MV2 didn't
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Better memory behavior.&lt;/strong&gt; SWs that aren't doing anything don't use RAM. For extensions that are mostly idle (like a cost tracker), this matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forced architectural discipline.&lt;/strong&gt; The constraints of MV3 push you toward patterns that are actually better: explicit state management, clear separation between UI and background logic, proper async handling. The code is better for it, even if it took longer to write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firefox compatibility.&lt;/strong&gt; Firefox adopted MV3 (with some differences). Building MV3-native means the Firefox port is substantially closer. That's on my roadmap.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one thing I'd tell someone starting today
&lt;/h2&gt;

&lt;p&gt;Read the &lt;a href="https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle" rel="noopener noreferrer"&gt;Service Worker lifecycle documentation&lt;/a&gt; before writing a single line of background code. Not the "getting started" guide — the lifecycle specifically. Understanding that the SW can and will be terminated between events changes how you design everything.&lt;/p&gt;

&lt;p&gt;I didn't read it carefully enough before starting. I paid for that with two days of debugging behavior that made no sense until I understood the lifecycle.&lt;/p&gt;




&lt;p&gt;All the decisions behind this are documented in &lt;a href="https://github.com/projekta2/build-logs" rel="noopener noreferrer"&gt;Build Logs&lt;/a&gt; — a public engineering journal where I write up real decisions from building these extensions, including the ones that were wrong.&lt;/p&gt;

&lt;p&gt;The extensions themselves: &lt;a href="https://chromewebstore.google.com/detail/pr-focus-ai-pro/ememaiabefeojkccjclglcmbjmdpnaoe" rel="noopener noreferrer"&gt;PR Focus Pro&lt;/a&gt; (AI PR triage) and &lt;a href="https://chromewebstore.google.com/detail/tabcost-pro/oifegknejkfiibmfapdfcgemclgmmghm" rel="noopener noreferrer"&gt;TabCost Pro&lt;/a&gt; (idle tab cost tracker).&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Designing developer tools that developers actually use – 5 UX principles I learned building Chrome extensions</title>
      <dc:creator>Projekta2</dc:creator>
      <pubDate>Tue, 23 Jun 2026 16:37:23 +0000</pubDate>
      <link>https://dev.to/projekta2/designing-developer-tools-that-developers-actually-use-5-ux-principles-i-learned-building-chrome-3lla</link>
      <guid>https://dev.to/projekta2/designing-developer-tools-that-developers-actually-use-5-ux-principles-i-learned-building-chrome-3lla</guid>
      <description>&lt;p&gt;Most developer tools are built by developers. That's both a strength and a weakness.&lt;/p&gt;

&lt;p&gt;Developers know what other developers need. But they also tend to build tools that only they understand — with cryptic error messages, hidden features, and UI that requires a tutorial.&lt;/p&gt;

&lt;p&gt;After building three Chrome extensions (TabCost, PR Focus, and ChainTrace), I've learned a few principles that separate "tools developers use" from "tools developers install and forget."&lt;/p&gt;

&lt;p&gt;Here are 5 principles I follow now.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The "one clear thing" rule
&lt;/h2&gt;

&lt;p&gt;Every tool should have one clear job. If a user can't explain what your tool does in one sentence, your tool is too complex.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I used to do:&lt;/strong&gt; Add features because they were cool, not because they solved a problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I do now:&lt;/strong&gt; Start with one core feature. Add others only when users ask for them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example from PR Focus:&lt;/strong&gt; The core feature is "sort GitHub PRs by priority." Everything else (AI summaries, risk scoring, draft reviews) is layered on top. If the sorting didn't work, nothing else would matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Error messages that teach
&lt;/h2&gt;

&lt;p&gt;Bad error message: "Error: failed to authenticate."&lt;/p&gt;

&lt;p&gt;Good error message: "Your API key is invalid. Generate a new one at [link] and paste it in Settings."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; Every error message should tell the user what happened, why it happened, and what to do next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example from PR Focus:&lt;/strong&gt;&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
text
❌ "Invalid API key"

✅ "Your Groq API key is missing the 'read' permission. Please regenerate it with 'read' scope. [Learn how →]"
3. Preserve user state
Developers are context-switchers. They open your tool, then get interrupted. When they come back, everything should be exactly where they left it.

What I do:

Save settings in chrome.storage.sync.

Save progress (checklist state) in chrome.storage.local.

No "start over" unless the user explicitly asks.

Example from PR Review Canvas: The interactive checklist saves progress in localStorage. Users can close the tab, come back days later, and continue where they left off.

4. Expose the "why"
Developers are curious. If your tool does something unexpected, they'll want to know why.

What I do:

Add tooltips to non-obvious features.

Include a "why" for every checklist item (PR Review Canvas).

Show logs or reasoning where possible.

Example from PR Focus: When the AI risk score is low, the tool shows the reasoning: "CI passing, PR age 2 days, changes to readme only → low risk."

5. Dark mode (done right)
Every developer tool needs dark mode. But it needs to work — not just be "dark gray instead of light gray."

What I do:

Use system theme detection as default.

Provide a toggle that actually works.

Test on both light and dark.

Example: PR Focus supports dark/light/system themes. The toggle is always visible, and the theme persists across sessions.

The checklist I use for new features
Before shipping any new feature, I ask:

Does this solve a real problem?

Can I explain it in one sentence?

Does it work without a tutorial?

Are the error messages helpful?

Is the state preserved across sessions?

Does it have dark mode?

If the answer to any is "no," I don't ship it.

Final thought
Developers are the hardest audience to please. They see through marketing fluff. They notice when something is broken. They'll uninstall your tool the moment it wastes their time.

But if you build something that saves them time — without getting in their way — they'll tell everyone they know.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>design</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Migrating a Chrome extension from MV2 to MV3: what broke, how I fixed it, and what I'd do differently</title>
      <dc:creator>Projekta2</dc:creator>
      <pubDate>Tue, 23 Jun 2026 16:35:25 +0000</pubDate>
      <link>https://dev.to/projekta2/migrating-a-chrome-extension-from-mv2-to-mv3-what-broke-how-i-fixed-it-and-what-id-do-c1e</link>
      <guid>https://dev.to/projekta2/migrating-a-chrome-extension-from-mv2-to-mv3-what-broke-how-i-fixed-it-and-what-id-do-c1e</guid>
      <description>&lt;p&gt;Google's Manifest V3 deadline is here. If you have a Chrome extension still running on MV2, you're on borrowed time.&lt;/p&gt;

&lt;p&gt;I migrated PR Focus (and two other extensions) from MV2 to MV3. Nothing broke catastrophically — but several things surprised me.&lt;/p&gt;

&lt;p&gt;Here's what I learned, so you don't have to figure it out the hard way.&lt;/p&gt;




&lt;h2&gt;
  
  
  The big changes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Background pages → Service workers
&lt;/h3&gt;

&lt;p&gt;This is the biggest shift. In MV2, background scripts run continuously. In MV3, they're service workers that sleep when idle.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Persistent connections (like WebSockets) needed reconnection logic.&lt;/li&gt;
&lt;li&gt;Timers and intervals didn't survive sleep cycles.&lt;/li&gt;
&lt;li&gt;Global state wasn't preserved between events.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How I fixed it:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Replaced &lt;code&gt;setInterval&lt;/code&gt; with &lt;code&gt;chrome.alarms&lt;/code&gt; (which wakes the service worker).&lt;/li&gt;
&lt;li&gt;Stored state in &lt;code&gt;chrome.storage.local&lt;/code&gt; instead of in-memory variables.&lt;/li&gt;
&lt;li&gt;Added reconnection logic for WebSocket connections.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Code example:&lt;/strong&gt;&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
javascript
// MV2 — background script (runs forever)
setInterval(() =&amp;gt; {
  checkForNewPRs();
}, 60000);

// MV3 — service worker (can sleep)
chrome.alarms.create('checkPRs', { periodInMinutes: 1 });
chrome.alarms.onAlarm.addListener((alarm) =&amp;gt; {
  if (alarm.name === 'checkPRs') {
    checkForNewPRs();
  }
});

2. Remote code execution is dead
MV3 completely bans remote code execution. No more eval(), new Function(), or loading scripts from external URLs.

What broke:

Dynamic script injection (if you were using it).

Remote code fetching (like loading a script from a CDN).

How I fixed it:

Bundled everything locally.

Replaced dynamic evaluation with explicit imports.

Used chrome.scripting.executeScript() with local files instead of stringified code.

3. Host permissions are now optional
MV2 required all permissions up front. MV3 lets you request permissions at runtime.

What changed:

Users can install without granting all permissions.

You can request sensitive permissions (like tabs or activeTab) only when needed.

How I used it:

PR Focus only requests activeTab when the user opens the popup.

No scary permission prompts at install time.

4. WebRequest blocking is gone
MV3 removed blocking capabilities from webRequest API. You can't intercept and modify requests anymore.

What broke:

Ad blockers and request-modifying extensions.

How I worked around it:

PR Focus doesn't need this API, so no change required. But if you're using it, you'll need declarativeNetRequest instead — which is less powerful and more restrictive.

What I'd do differently
1. Test with the service worker lifecycle earlier
The service worker sleep behavior is hard to debug. I spent days chasing issues that only appeared after 5 minutes of inactivity. Lesson learned: test the idle behavior early.

2. Use chrome.storage.local for everything
MV2 let me rely on in-memory state. MV3 doesn't. I should have moved all state to storage.local from day one. Now I have a mix of both, and it's messy.

3. Plan for the permission model
MV3's runtime permission model is a feature, not a bug. I should have designed PR Focus with optional permissions from the start. Instead, I had to refactor.

The migration checklist
Here's what I'd recommend for any MV2→MV3 migration:

Replace background.scripts with background.service_worker in manifest.json.

Convert background scripts to service workers (no window, no document).

Move all state to chrome.storage.local.

Replace setInterval/setTimeout with chrome.alarms.

Remove all eval() and new Function() calls.

Test service worker idle behavior.

Move permissions to optional where possible.

Use chrome.scripting instead of chrome.tabs.executeScript.

Update host_permissions to match the new model.

Final thoughts
MV3 isn't as bad as the early complaints suggested. The service worker model is better for performance and battery life. The permission model is better for privacy.

But the migration takes time. Don't wait until the last minute. Start now.

If you're migrating a Chrome extension and get stuck, I'm happy to help. [Drop me an issue on GitHub.](https://github.com/projekta2/pr-focus-landing/issues)



&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>mv3</category>
      <category>javascript</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Freemium vs. one-time vs. subscription: how I chose the pricing model for my Chrome extension</title>
      <dc:creator>Projekta2</dc:creator>
      <pubDate>Tue, 23 Jun 2026 16:32:02 +0000</pubDate>
      <link>https://dev.to/projekta2/freemium-vs-one-time-vs-subscription-how-i-chose-the-pricing-model-for-my-chrome-extension-4jan</link>
      <guid>https://dev.to/projekta2/freemium-vs-one-time-vs-subscription-how-i-chose-the-pricing-model-for-my-chrome-extension-4jan</guid>
      <description>&lt;p&gt;When I launched my first Chrome extension, I had no idea how to price it. I just knew I didn't want to charge a subscription.&lt;/p&gt;

&lt;p&gt;Three extensions later, I've learned a lot about what works — and what doesn't. This is the framework I use now.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three models
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Subscription (recurring revenue)
&lt;/h3&gt;

&lt;p&gt;Most SaaS advice tells you subscriptions are the only way to build a sustainable business. But for Chrome extensions, subscriptions come with a hidden tax: &lt;strong&gt;user fatigue.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every time a user sees a subscription prompt, they ask: "Is this really worth $X/month forever?" For a tool that saves 30 minutes a day, yes. For a tool that saves 5 minutes a week, no.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The tool provides ongoing value (not just one-time)&lt;/li&gt;
&lt;li&gt;You have server costs that scale with users&lt;/li&gt;
&lt;li&gt;Your users are businesses (not individuals)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;When it doesn't:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your extension is a simple utility&lt;/li&gt;
&lt;li&gt;Your users are individuals with limited budgets&lt;/li&gt;
&lt;li&gt;Your competition offers a free alternative&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. One-time payment
&lt;/h3&gt;

&lt;p&gt;This is what I chose for PR Focus ($9.50 one-time, lifetime access). It's a bet on two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Users trust you enough to buy upfront&lt;/li&gt;
&lt;li&gt;You can support the product without recurring revenue&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The upside:&lt;/strong&gt; Users love it. No subscription fatigue. No "I'll cancel after I try it." Just a simple transaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The downside:&lt;/strong&gt; No recurring revenue. You need to keep acquiring new users forever to sustain growth. And if you stop marketing, revenue stops.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Freemium (free + paid features)
&lt;/h3&gt;

&lt;p&gt;This is the most common model for developer tools. Give away enough value to build trust, then charge for the premium features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The upside:&lt;/strong&gt; Low friction to start. Users can try before they buy. Word-of-mouth spreads the free version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The downside:&lt;/strong&gt; Conversion rates are usually 2-5%. You need a large free user base to make the math work. And you need to clearly differentiate free vs. paid.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I chose for PR Focus
&lt;/h2&gt;

&lt;p&gt;I started with freemium + one-time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free tier:&lt;/strong&gt; multi-account GitHub switching, PR sorting/export, stale notifications. (Enough to be useful, not enough to be complete.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pro tier:&lt;/strong&gt; AI summaries, risk scoring, one-click draft reviews, full stats. ($9.50 one-time.)&lt;/p&gt;

&lt;p&gt;Why not subscription?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I'm a solo developer.&lt;/strong&gt; Subscriptions mean handling churn, dunning emails, payment failures, and support for "I forgot to cancel" complaints. That's a full-time job on top of building the product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My users are individuals, not companies.&lt;/strong&gt; A developer on a personal budget is less likely to commit to $10/month forever. But they'll pay $10 once if the value is clear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI costs are passed through (BYOK).&lt;/strong&gt; I don't pay for AI usage — users bring their own key. So I don't have a per-user cost that needs a subscription to cover.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The result:&lt;/strong&gt; Freemium + one-time Pro has been the right balance. Users get value from the free tier, and the ones who need AI features pay once. No one feels trapped.&lt;/p&gt;




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

&lt;p&gt;If I were starting over, I'd:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Launch with a paid beta.&lt;/strong&gt; I gave away too much for free early on. A small beta fee (even $1) filters for genuine users and gives you early revenue to reinvest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Charge more.&lt;/strong&gt; $9.50 is low. I was afraid of pricing too high, but the users who get value from AI features would pay $20-30 easily. Next time, I'm starting higher.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the waitlist earlier.&lt;/strong&gt; I had 0 users before launch. A waitlist of 100 interested developers would have validated the idea and given me launch momentum.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The framework I use now
&lt;/h2&gt;

&lt;p&gt;Before choosing a pricing model, I ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does this tool solve a recurring or one-time problem?&lt;/strong&gt; Recurring → subscription. One-time → one-time payment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do I have ongoing costs per user?&lt;/strong&gt; Yes → subscription. No → one-time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is my target audience individuals or businesses?&lt;/strong&gt; Individuals → lower price, simpler model. Businesses → higher price, subscriptions acceptable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can I give away enough value to build trust?&lt;/strong&gt; Yes → freemium. No → paid-only.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For PR Focus, the answers were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Recurring problem (code review is daily) → one-time (because I don't have per-user costs)&lt;/li&gt;
&lt;li&gt;No ongoing costs (BYOK) → one-time&lt;/li&gt;
&lt;li&gt;Individuals → simple model&lt;/li&gt;
&lt;li&gt;Yes, core features are useful → freemium&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Key takeaway
&lt;/h2&gt;

&lt;p&gt;There's no one "right" pricing model. The best model depends on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your costs&lt;/li&gt;
&lt;li&gt;Your audience&lt;/li&gt;
&lt;li&gt;Your product type&lt;/li&gt;
&lt;li&gt;Your risk tolerance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the worst model is &lt;strong&gt;no model&lt;/strong&gt; — launching with "just free" and hoping to figure it out later. Have a plan before you launch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're building a Chrome extension and want to talk pricing, I'm always open to chat. &lt;a href="https://github.com/projekta2" rel="noopener noreferrer"&gt;Find me on GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>indiedev</category>
      <category>sass</category>
      <category>pricing</category>
    </item>
    <item>
      <title>The PR Review Canvas – a free interactive checklist for better code reviews</title>
      <dc:creator>Projekta2</dc:creator>
      <pubDate>Tue, 23 Jun 2026 13:24:09 +0000</pubDate>
      <link>https://dev.to/projekta2/the-pr-review-canvas-a-free-interactive-checklist-for-better-code-reviews-5dgi</link>
      <guid>https://dev.to/projekta2/the-pr-review-canvas-a-free-interactive-checklist-for-better-code-reviews-5dgi</guid>
      <description>&lt;p&gt;Code review is one of the most valuable activities in software development — but also one of the most inconsistent.&lt;/p&gt;

&lt;p&gt;I've seen teams where every review is a tense negotiation, and others where "LGTM" is the default response to every PR. Both extremes are problematic.&lt;/p&gt;

&lt;p&gt;After years of reviewing PRs and building tools to automate parts of the process, I realised that what most teams lack is a &lt;strong&gt;shared, structured mental model&lt;/strong&gt; of what a good review actually covers.&lt;/p&gt;

&lt;p&gt;So I built the &lt;strong&gt;PR Review Canvas&lt;/strong&gt; — a free, open‑source kit that makes the reviewer's mental model explicit.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎯 What's inside
&lt;/h2&gt;

&lt;h3&gt;
  
  
  📋 51‑item interactive checklist
&lt;/h3&gt;

&lt;p&gt;A live web tool with a "Review Readiness" gauge, collapsible categories, dark/light mode, and progress saved in your browser. No install, no account, no tracking.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://projekta2.github.io/pr-review-canvas/" rel="noopener noreferrer"&gt;&lt;strong&gt;Try it live&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  📝 PR description templates
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Basic template&lt;/strong&gt; for everyday changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advanced template&lt;/strong&gt; for complex, high‑risk PRs (with risk/rollback tables).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  📖 Step‑by‑step guides
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;For first‑time reviewers — how to not feel overwhelmed.&lt;/li&gt;
&lt;li&gt;For experienced reviewers — techniques for reviewing fast at scale.&lt;/li&gt;
&lt;li&gt;How to give constructive feedback (with actual phrases to use and retire).&lt;/li&gt;
&lt;li&gt;How to receive feedback without taking it personally.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  📚 Annotated examples
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A well‑written PR — annotated to show &lt;em&gt;why&lt;/em&gt; it works.&lt;/li&gt;
&lt;li&gt;A poorly‑written PR — annotated to show &lt;em&gt;how to fix it&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;A full review transcript — comment by comment, with reasoning.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🧠 Code review anti‑patterns
&lt;/h3&gt;

&lt;p&gt;A reference list of the recurring ways review goes wrong — on both sides of the diff.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔗 Ready‑to‑paste templates
&lt;/h3&gt;

&lt;p&gt;Import the checklist into Notion or Obsidian in one click.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚀 Why a checklist?
&lt;/h2&gt;

&lt;p&gt;A checklist isn't bureaucracy — it's a substitute for the senior engineer who isn't always available to stand over your shoulder.&lt;/p&gt;

&lt;p&gt;The categories in this kit map to the actual sequence an experienced reviewer's brain runs through:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Context &amp;amp; Purpose&lt;/li&gt;
&lt;li&gt;Architecture &amp;amp; Design&lt;/li&gt;
&lt;li&gt;Code Quality&lt;/li&gt;
&lt;li&gt;Testing&lt;/li&gt;
&lt;li&gt;Performance &amp;amp; Security&lt;/li&gt;
&lt;li&gt;Documentation&lt;/li&gt;
&lt;li&gt;Standards Compliance&lt;/li&gt;
&lt;li&gt;Final Considerations&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each item includes a &lt;strong&gt;"why"&lt;/strong&gt; — so you're not just ticking boxes, you're understanding the reasoning behind each check.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎨 Try it live — right now
&lt;/h2&gt;

&lt;p&gt;You don't need to install anything. The interactive checklist is hosted on GitHub Pages:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://projekta2.github.io/pr-review-canvas/" rel="noopener noreferrer"&gt;&lt;strong&gt;projekta2.github.io/pr-review-canvas&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open it, check items off as you review, and export your notes when you're done. Your progress is saved locally in your browser.&lt;/p&gt;




&lt;h2&gt;
  
  
  📦 The repo
&lt;/h2&gt;

&lt;p&gt;Everything is &lt;strong&gt;MIT licensed&lt;/strong&gt;, so you can fork it, adapt it, and use it with your team:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/projekta2/pr-review-canvas" rel="noopener noreferrer"&gt;&lt;strong&gt;github.com/projekta2/pr-review-canvas&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Contributions are welcome — new checklist items, sharper feedback phrasing, translated versions, or a template for a workflow this doesn't cover yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  💡 From the same workshop
&lt;/h2&gt;

&lt;p&gt;This kit grew out of the same review fatigue that led to building other developer tools. But this one is &lt;strong&gt;free, open, and standalone&lt;/strong&gt; — you don't need anything else to use it.&lt;/p&gt;

&lt;p&gt;If you review code regularly, I'd love to hear what's missing or what could be sharper. Drop a comment below, open an issue on GitHub, or send me a message.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with care by &lt;a href="https://github.com/projekta2" rel="noopener noreferrer"&gt;Alexandre Iglesias&lt;/a&gt; – Girona, Spain.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>codereview</category>
      <category>github</category>
      <category>opensource</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I was spending 2 hours a day triaging GitHub PRs — so I built an AI extension to fix it</title>
      <dc:creator>Projekta2</dc:creator>
      <pubDate>Tue, 23 Jun 2026 08:46:47 +0000</pubDate>
      <link>https://dev.to/projekta2/i-was-spending-2-hours-a-day-triaging-github-prs-so-i-built-an-ai-extension-to-fix-it-10mm</link>
      <guid>https://dev.to/projekta2/i-was-spending-2-hours-a-day-triaging-github-prs-so-i-built-an-ai-extension-to-fix-it-10mm</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;I review a lot of pull requests. Not because I'm a reviewer by trade, but because I'm a solo developer running a few Chrome extensions. Every PR that comes in — whether it's from a contributor or my own — needs to be reviewed.&lt;/p&gt;

&lt;p&gt;At some point, I realized I was losing &lt;strong&gt;2 hours a day&lt;/strong&gt; just deciding what to review first. Not reviewing. Deciding.&lt;/p&gt;

&lt;p&gt;GitHub shows PRs in whatever order they were opened. A one-line typo fix and a PR touching authentication code get exactly the same visual weight. Multiply that across 14 open PRs, and you spend more time triaging than actually reviewing.&lt;/p&gt;

&lt;p&gt;I tried everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sorting by age (oldest first) → ignores urgency.&lt;/li&gt;
&lt;li&gt;Sorting by CI status (failing first) → better, but misses structural risk.&lt;/li&gt;
&lt;li&gt;Using labels and milestones → too manual.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;I built a Chrome extension called &lt;strong&gt;PR Focus&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It sits on top of GitHub and uses AI to do three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Score risk (0–100)&lt;/strong&gt; for every PR, based on CI status, PR age, and code scope (auth/DB/infra changes get higher risk).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate plain-English summaries&lt;/strong&gt; from the actual diff — not from the PR title someone wrote at 11pm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Draft review comments&lt;/strong&gt; in one click (approve or request changes).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result? My PR inbox is now sorted by priority, not by date. The risky PRs float to the top. The trivial ones wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key decision: BYOK
&lt;/h2&gt;

&lt;p&gt;Instead of running my own AI backend, I made PR Focus &lt;strong&gt;BYOK (Bring Your Own Key)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Users bring their own OpenAI, Groq, Mistral, or Ollama key. All AI requests go directly from their browser to the provider. I never see their data or their key.&lt;/p&gt;

&lt;p&gt;This was the deciding factor for trust: developers reviewing private repos can use it without worrying about their code hitting a third-party server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off
&lt;/h2&gt;

&lt;p&gt;The downside? User friction. Every user has to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create an account with Groq/OpenAI.&lt;/li&gt;
&lt;li&gt;Generate an API key.&lt;/li&gt;
&lt;li&gt;Paste it into the extension.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Some users bounce at this step. But the ones who stay appreciate the transparency — and Groq's free tier covers 95% of individual workflows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;You can test the live demo without installing anything:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://projekta2.github.io/pr-focus-landing/pr-focus-demo.html" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Or install the extension directly from the Chrome Web Store:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://chromewebstore.google.com/detail/pr-focus-ai-pro/ememaiabefeojkccjclglcmbjmdpnaoe" rel="noopener noreferrer"&gt;Install PR Focus Pro&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  I also document my decisions
&lt;/h2&gt;

&lt;p&gt;I write detailed engineering logs about every major decision behind PR Focus — from the hybrid risk scoring algorithm to why I chose BYOK. You can read them all here:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/projekta2/build-logs" rel="noopener noreferrer"&gt;Build Logs&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're a developer who reviews PRs regularly, I'd love to hear your feedback — what's your current workflow?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>github</category>
      <category>ai</category>
      <category>codereview</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why my Chrome extension uses a hybrid AI risk score instead of pure AI sorting</title>
      <dc:creator>Projekta2</dc:creator>
      <pubDate>Sun, 21 Jun 2026 10:02:12 +0000</pubDate>
      <link>https://dev.to/projekta2/why-my-chrome-extension-uses-a-hybrid-ai-risk-score-instead-of-pure-ai-sorting-4lfo</link>
      <guid>https://dev.to/projekta2/why-my-chrome-extension-uses-a-hybrid-ai-risk-score-instead-of-pure-ai-sorting-4lfo</guid>
      <description>&lt;p&gt;I've been building &lt;a href="https://github.com/projekta2/pr-focus-landing" rel="noopener noreferrer"&gt;PR Focus&lt;/a&gt;, a Chrome extension that helps developers triage GitHub pull requests. One of the first decisions I had to make was: &lt;strong&gt;how do I actually sort PRs by priority?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The obvious answer is "use AI to score the risk". But I didn't want to rely 100% on an LLM because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI is inconsistent (same diff, different scores).&lt;/li&gt;
&lt;li&gt;It costs users tokens on every poll.&lt;/li&gt;
&lt;li&gt;A wrong AI score can bury a broken PR.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built a &lt;strong&gt;hybrid&lt;/strong&gt; system: deterministic signals (CI status + PR age) form the floor, and the AI risk score is a tiebreaker on top. Failing CI always floats to the top, regardless of what the AI says.&lt;/p&gt;

&lt;p&gt;I wrote up the full decision, including the trade-offs and what it cost, in my new &lt;a href="https://github.com/projekta2/build-logs" rel="noopener noreferrer"&gt;Build Logs repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're building dev tools or wrestling with AI reliability, the full log might be useful:&lt;br&gt;&lt;br&gt;
🔗 &lt;a href="https://github.com/projekta2/build-logs/blob/main/001-pr-focus-risk-scoring.md" rel="noopener noreferrer"&gt;Why PR risk scoring is a hybrid, not a pure AI verdict&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>productivity</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built an AI priority inbox for GitHub pull requests — and went BYOK instead of running my own AI backend</title>
      <dc:creator>Projekta2</dc:creator>
      <pubDate>Sat, 20 Jun 2026 15:32:36 +0000</pubDate>
      <link>https://dev.to/projekta2/i-built-an-ai-priority-inbox-for-github-pull-requests-and-went-byok-instead-of-running-my-own-ai-19ij</link>
      <guid>https://dev.to/projekta2/i-built-an-ai-priority-inbox-for-github-pull-requests-and-went-byok-instead-of-running-my-own-ai-19ij</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;GitHub shows your pull requests in whatever order they happened to be opened — not in the order they actually need your attention. A one-line typo fix and a PR touching authentication code get exactly the same visual weight in your inbox. Multiply that across a dozen open PRs and you spend more time deciding what to look at than actually reviewing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;PR Focus is a Chrome extension (Manifest V3) that sits on top of GitHub's PR pages. It combines three signals into a single priority queue:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CI status&lt;/strong&gt; — failing checks bubble up&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PR age&lt;/strong&gt; — stale PRs don't get forgotten&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI risk score (0–100)&lt;/strong&gt; — weighted toward changes touching auth, database, or infra code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each PR also gets a plain-English summary generated from the actual diff (not the title someone wrote at 11pm), and you can generate an approve / request-changes draft review in one click, edit it, and send — without leaving the extension.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why BYOK instead of my own AI backend
&lt;/h2&gt;

&lt;p&gt;This was the decision I spent the most time on. Running my own AI backend would have meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A server in the data path of every PR diff users review — a much bigger trust ask, especially for private repos.&lt;/li&gt;
&lt;li&gt;Either eating the AI cost myself (unsustainable as a solo dev) or marking it up into a subscription.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Going BYOK (bring your own key — OpenAI, Groq, Mistral, or a local Ollama instance) flips both of those:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your GitHub token and AI key live in &lt;code&gt;chrome.storage.local&lt;/code&gt;. There's no server of mine in the path — PR diffs only ever go to the AI provider you explicitly configure.&lt;/li&gt;
&lt;li&gt;Groq's free tier is generous enough to run the AI features for free for most individual workflows. You're paying provider cost directly, with zero markup, if you pay anything at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it's built
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Manifest V3&lt;/strong&gt; — required rethinking persistence patterns that worked under MV2's persistent background page; service worker lifecycle and content script injection needed more careful handling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub REST + GraphQL APIs&lt;/strong&gt; rather than DOM scraping — more upfront work, but it doesn't break every time GitHub ships a frontend redesign.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IndexedDB&lt;/strong&gt; for local data persistence (capture history, stats).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gumroad&lt;/strong&gt; for license validation instead of a subscription backend — much less infrastructure to maintain solo.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pricing model
&lt;/h2&gt;

&lt;p&gt;Free tier: multi-account GitHub switching, PR sorting/export, stale-PR notifications.&lt;br&gt;
PRO ($9.50 one-time, currently 50% off the $19 regular price): AI summaries, risk scoring, one-click draft reviews, full stats history, AI-based priority sorting. No subscription — pay once, own it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Currently solo-maintained, iterating on feedback from the first wave of GitHub stars and a community-contributed PR that got merged — which, honestly, felt like a bigger validation moment than any launch metric so far. Performance-regression detection and broader language support (Python, Go) are next on the roadmap.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://chromewebstore.google.com/detail/pr-focus-ai-pro/ememaiabefeojkccjclglcmbjmdpnaoe" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/pr-focus-ai-pro/ememaiabefeojkccjclglcmbjmdpnaoe&lt;/a&gt;&lt;br&gt;
Landing + demo: &lt;a href="https://projekta2.github.io/pr-focus-landing/" rel="noopener noreferrer"&gt;https://projekta2.github.io/pr-focus-landing/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Feedback welcome — especially from anyone who's built browser extensions against the GitHub API, or who has opinions on BYOK UX.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>github</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I built 3 Chrome extensions from scratch as a solo developer – here's what I learned (code included)</title>
      <dc:creator>Projekta2</dc:creator>
      <pubDate>Thu, 11 Jun 2026 22:56:12 +0000</pubDate>
      <link>https://dev.to/projekta2/i-built-3-chrome-extensions-from-scratch-as-a-solo-developer-heres-what-i-learned-code-included-5c1p</link>
      <guid>https://dev.to/projekta2/i-built-3-chrome-extensions-from-scratch-as-a-solo-developer-heres-what-i-learned-code-included-5c1p</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;██████╗ ██╗  ██╗    ███████╗██╗  ██╗████████╗███████╗
██╔══██╗██║ ██╔╝    ██╔════╝╚██╗██╔╝╚══██╔══╝╚════██║
██████╔╝█████╔╝     █████╗   ╚███╔╝    ██║       ██╔╝
██╔═══╝ ██╔═██╗     ██╔══╝   ██╔██╗    ██║      ██╔╝
██║     ██║  ██╗    ███████╗██╔╝ ██╗   ██║      ██║
╚═╝     ╚═╝  ╚═╝    ╚══════╝╚═╝  ╚═╝   ╚═╝      ╚═╝
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;by &lt;a href="https://github.com/projekta2" rel="noopener noreferrer"&gt;@projekta2&lt;/a&gt;&lt;/strong&gt;  ·  3 live extensions  ·  $0 in VC  ·  ~6 months&lt;/p&gt;




&lt;p&gt;I had never written a Chrome extension in my life.&lt;/p&gt;

&lt;p&gt;Six months later I had three live products on the Chrome Web Store. They are brand new – no inflated user counts, no fake reviews, no made‑up conversion rates. Just honest work and a lot of learning.&lt;/p&gt;

&lt;p&gt;This article is a &lt;strong&gt;technical postmortem&lt;/strong&gt;. I'll share the architecture decisions, the licensing code that actually works, the pricing mistakes I made, and what I would do differently. No hype. No "I made $10k in my first month". Just real lessons from a solo developer who started from zero.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reading time: ~12 minutes&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🛠️ The three products
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://chrome.google.com/webstore/detail/tabcost-pro/oifegknejkfiibmfapdfcgemclgmmghm" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;💸 TabCost Pro — Install free&lt;/a&gt;
&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Opportunity cost tracker for idle browser tabs.&lt;/strong&gt; Set your hourly rate. The popup shows how much your inactive tabs have cost you today. Freemium · &lt;strong&gt;$5 lifetime Pro.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://chromewebstore.google.com/detail/pr-focus-ai-pro/ememaiabefeojkccjclglcmbjmdpnaoe" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;🔍 PR Focus Pro — Install free&lt;/a&gt;
&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;AI assistant for GitHub pull requests.&lt;/strong&gt; Summarises diffs, scores risk, helps you prioritise your review queue. Freemium · &lt;strong&gt;$9.50 lifetime Pro.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://projekta2.github.io/chaintrace-landing/" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⛓️ ChainTrace — Landing + demo&lt;/a&gt;
&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Supply chain data extraction.&lt;/strong&gt; Auto‑detects tables on Alibaba, SAP Ariba, Shopify or internal ERPs and exports to Google Sheets in one click. Freemium · &lt;strong&gt;$49 lifetime Premium.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;All three are live but &lt;strong&gt;brand new&lt;/strong&gt;. No user numbers to boast about yet – that's the honest state.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: Technical decisions that worked
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ⚙️ MV3 is not the enemy
&lt;/h3&gt;

&lt;p&gt;When I started, Manifest V3 had a bad reputation. &lt;em&gt;"Service workers terminate unexpectedly." "Too restrictive."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After building three extensions on it, I found that &lt;strong&gt;starting fresh on MV3 is fine&lt;/strong&gt;. The constraints force a cleaner architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The thing that bit me most:&lt;/strong&gt; service workers terminate after ~30 seconds of inactivity. You must design around it.&lt;/p&gt;

&lt;p&gt;TabCost's cost engine runs on a 1‑minute &lt;code&gt;chrome.alarms&lt;/code&gt; tick. This is the pattern that works:&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="c1"&gt;// background.js — cost engine driven by alarms&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="s2"&gt;minuteTicker&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;periodInMinutes&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="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="s2"&gt;heartbeat&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;periodInMinutes&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="k"&gt;async &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="s2"&gt;minuteTicker&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkMidnightRotation&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;calculateOpportunityCost&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;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="s2"&gt;heartbeat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ensureAlarms&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// recreates minuteTicker if it went missing&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stored&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;lastCalculationTimestamp&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;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastCalculationTimestamp&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;last&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="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="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;calculateOpportunityCost&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// fallback recalc&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;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The heartbeat alarm is critical.&lt;/strong&gt; Without it, &lt;code&gt;minuteTicker&lt;/code&gt; can disappear after Chrome restarts. I only discovered this from a bug report I couldn't reproduce locally.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The cost calculation uses an elapsed‑time delta model, capped at 15 minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculateOpportunityCost&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;now&lt;/span&gt; &lt;span class="o"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stored&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;lastCalculationTimestamp&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;lastTimestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastCalculationTimestamp&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;lastTimestamp&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;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;lastCalculationTimestamp&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="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="c1"&gt;// Cap at 15 min – if Chrome slept for 2h, don't charge 120 minutes at once&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;elapsedMinutes&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;min&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastTimestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&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;elapsedMinutes&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="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;lastCalculationTimestamp&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hourlyRate&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;const&lt;/span&gt; &lt;span class="nx"&gt;grace&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;max&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="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;graceMinutes&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;impact&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;impactPercent&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tabs&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;addedCost&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;tab&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;tabs&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="nf"&gt;isTabIgnored&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="k"&gt;continue&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;lastTime&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tabLastInteraction&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;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;now&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;inactiveMinutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&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;inactiveMinutes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;grace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;addedCost&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;impact&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;elapsedMinutes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="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;dailyCost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;dailyCost&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;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;dailyCost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;addedCost&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 15‑minute cap is conservative and always in the user's favour – no one gets charged for hours when the browser was suspended.&lt;/p&gt;




&lt;h3&gt;
  
  
  🔑 Gumroad licensing: the implementation nobody writes about
&lt;/h3&gt;

&lt;p&gt;Most tutorials skip licensing entirely. Here's what actually works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setup:&lt;/strong&gt; user buys on Gumroad → enters license key in extension → extension verifies with Gumroad API → stores a fingerprinted result locally.&lt;/p&gt;

&lt;p&gt;The fingerprint stops casual key‑sharing:&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="c1"&gt;// Deterministic fingerprint: license key + a secret salt&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;_LS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TC_fp_v1_prod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// never changes between versions&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeLicenseFingerprint&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5381&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;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;_LS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;s&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="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;h&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="mi"&gt;36&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 activation – verify with Gumroad, then store key + fingerprint&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;verifyGumroadLicense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;licenseKey&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;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="s2"&gt;https://api.gumroad.com/v2/licenses/verify&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="s2"&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="s2"&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="s2"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&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="s2"&gt;`product_id=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;license_key=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;licenseKey&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;result&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;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;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uses_per_license_exceeded&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;isValid&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;fingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computeLicenseFingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;licenseKey&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;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;isPro&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;licenseKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;licenseFingerprint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&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;On every Chrome startup, verify the stored fingerprint hasn't been tampered with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyStoredLicense&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;isPro&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;licenseKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;licenseFingerprint&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;isPro&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;licenseKey&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;licenseFingerprint&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="o"&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="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;licenseKey&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;licenseFingerprint&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;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;isPro&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="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;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computeLicenseFingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;licenseKey&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;expected&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;licenseFingerprint&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;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;isPro&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;licenseKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;licenseFingerprint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ This won't stop a determined attacker – they can find &lt;code&gt;_LS&lt;/code&gt; in the source. But it stops &lt;strong&gt;95% of casual key‑sharing&lt;/strong&gt;. For a $5 product, that's the right trade‑off.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What I'd add if starting again: a daily server‑side re‑validation call to Gumroad's &lt;code&gt;/licenses/verify&lt;/code&gt; endpoint. I have it stubbed but not fully wired.&lt;/p&gt;




&lt;h3&gt;
  
  
  📊 GraphQL vs REST for GitHub data (PR Focus Pro)
&lt;/h3&gt;

&lt;p&gt;PR Focus needs PR metadata, CI status, diff stats, and review state for 10–20 PRs at once. GitHub's REST API would require 4–5 round trips per PR – too chatty.&lt;/p&gt;

&lt;p&gt;I went &lt;strong&gt;hybrid&lt;/strong&gt;: GraphQL for the bulk fetch, REST for actions (posting reviews, closing PRs).&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="c1"&gt;// One query – everything needed for a risk score&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GET_PRS_WITH_CONTEXT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
  query GetOpenPRs($owner: String!, $repo: String!, $cursor: String) {
    repository(owner: $owner, name: $repo) {
      pullRequests(first: 20, states: OPEN, after: $cursor) {
        pageInfo { hasNextPage endCursor }
        nodes {
          number
          title
          createdAt
          additions
          deletions
          changedFiles
          commits(last: 1) {
            nodes { commit { statusCheckRollup { state } } }
          }
          reviewDecision
          labels(first: 5) { nodes { name } }
        }
      }
    }
  }
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The risk score is computed client‑side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeRiskScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pr&lt;/span&gt;&lt;span class="p"&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;score&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="c1"&gt;// CI status – 40 points max&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;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ciState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FAILURE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;40&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;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ciState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PENDING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// PR age in days – 20 points max&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ageDays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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="nx"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="nx"&gt;_400_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;score&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ageDays&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Lines changed – 20 points max&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;linesChanged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;additions&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deletions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;score&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;linesChanged&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Sensitive areas (auth, security, db, infra) – 20 points max&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sensitivePattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/auth|security|payment|migration|database|infra/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isSensitive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sensitivePattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pr&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="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sensitivePattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isSensitive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&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;Simple, explainable, zero‑latency once the data is fetched. Users see &lt;strong&gt;why&lt;/strong&gt; a PR scores high – the breakdown is shown in the UI – which reduces support tickets.&lt;/p&gt;




&lt;h3&gt;
  
  
  💾 Zero‑backend storage: 365 days in chrome.storage.local
&lt;/h3&gt;

&lt;p&gt;TabCost stores a full year of daily cost history. &lt;strong&gt;No server, no database.&lt;/strong&gt; Just &lt;code&gt;chrome.storage.local&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;chrome.storage.local&lt;/code&gt; has a 10MB quota. One year of daily entries is about &lt;strong&gt;15KB&lt;/strong&gt; – negligible.&lt;/p&gt;

&lt;p&gt;Midnight rotation pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkMidnightRotation&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;lastSavedDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dailyCost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dailyHistory&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;lastSavedDate&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;dailyCost&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;dailyHistory&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;todayStr&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="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;T&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastSavedDate&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;todayStr&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;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dailyHistory&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lastSavedDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dailyCost&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="c1"&gt;// Free tier: 90 days max. Pro: 365 days.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;maxHistory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;config&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="mi"&gt;365&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&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;history&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="nx"&gt;maxHistory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&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;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;dailyHistory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dailyCost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastSavedDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;todayStr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;notifiedToday&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Called on every &lt;code&gt;minuteTicker&lt;/code&gt; tick. Atomic – the write either succeeds or it doesn't. Works correctly after a Chrome restart because &lt;code&gt;lastSavedDate&lt;/code&gt; persists.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: Product and pricing decisions (honest lessons)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  💰 One‑time pricing, no subscriptions
&lt;/h3&gt;

&lt;p&gt;All three use one‑time pricing ($5, $9.50, $49). No subscriptions.&lt;/p&gt;

&lt;p&gt;Subscriptions create pressure to retain users regardless of ongoing value. One‑time pricing means users feel they &lt;strong&gt;own&lt;/strong&gt; the tool. That ownership produces better reviews, more patience with bugs, and – counterintuitively – more willingness to recommend.&lt;/p&gt;




&lt;h3&gt;
  
  
  🆓 Freemium is harder than expected
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Mistake with TabCost:&lt;/strong&gt; the free tier gives too much (daily cost, tab audit, close‑all‑inactive, bilingual UI). Pro adds history, domain whitelist, auto‑close, notifications. Users get ~80% of the value for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What ChainTrace does differently:&lt;/strong&gt; free is capped at 50 captures/month and 500 rows. Pro is unlimited. The limit hits you &lt;em&gt;while using the tool&lt;/em&gt;, not as a locked feature you might never discover.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Lesson:&lt;/strong&gt; freemium gates that activate during normal use convert better than gates on features users may not discover.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"You've used 47 of 50 captures this month"&lt;/em&gt; is a better nudge than &lt;em&gt;"Upgrade to access the history chart."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Gate type&lt;/th&gt;
&lt;th&gt;Conversion signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TabCost (mistake)&lt;/td&gt;
&lt;td&gt;Feature locked behind Pro&lt;/td&gt;
&lt;td&gt;User may never discover it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ChainTrace (better)&lt;/td&gt;
&lt;td&gt;Usage cap hit mid-workflow&lt;/td&gt;
&lt;td&gt;User feels the limit in real time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h3&gt;
  
  
  🎯 Niche beats broad
&lt;/h3&gt;

&lt;p&gt;ChainTrace solves a specific operational pain: procurement professionals copy‑pasting from supplier portals every week. They understand the value immediately. No convincing needed.&lt;/p&gt;

&lt;p&gt;TabCost's target (freelancers wanting to be more focused) requires behaviour change first. That's harder.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Implication:&lt;/strong&gt; a tool that solves a &lt;strong&gt;daily operational pain point&lt;/strong&gt; outperforms a productivity tool that needs behaviour change to deliver value.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  📄 The README as a marketing asset
&lt;/h3&gt;

&lt;p&gt;TabCost's source README is 570 lines. Problem statement, feature table, architecture diagram, code walkthrough, pricing comparison, roadmap, contributing guide.&lt;/p&gt;

&lt;p&gt;The structure that works, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Problem statement in prose&lt;/strong&gt; – specifics matter&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The output&lt;/strong&gt; – the number or thing users actually see&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature table:&lt;/strong&gt; free vs. paid, honest about limits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture block&lt;/strong&gt; – proves technical credibility&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roadmap&lt;/strong&gt; – shows the product isn't abandoned&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributing section&lt;/strong&gt; – creates community&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Part 3: What I'd do differently (with hindsight)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Validate before building
&lt;/h3&gt;

&lt;p&gt;I built TabCost before posting anywhere. Demand existed – luck, not skill. Next time I'll post to HackerNews or a Discord community first.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Tighter free tier
&lt;/h3&gt;

&lt;p&gt;TabCost free gives away too much. Should be 50% of value, not 80%. Discomfort of the limit drives conversions.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Launch is a separate project
&lt;/h3&gt;

&lt;p&gt;I shipped TabCost in January. I "launched" it in March. Two months of zero users. Right sequence:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Timing&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;–2 weeks&lt;/td&gt;
&lt;td&gt;Landing page live&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Day 0&lt;/td&gt;
&lt;td&gt;Dev.to article&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Day 0 + 24h&lt;/td&gt;
&lt;td&gt;HackerNews "Show HN"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Day 1–2&lt;/td&gt;
&lt;td&gt;2–3 targeted subreddits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Day 2–3&lt;/td&gt;
&lt;td&gt;ProductHunt (after initial traction)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  4. Ask for reviews at the first win
&lt;/h3&gt;

&lt;p&gt;I waited 3 months. The right moment is when the user gets their &lt;strong&gt;first result&lt;/strong&gt; – first export, first PR summary, first "you saved $X today". Sentiment is highest exactly then.&lt;/p&gt;




&lt;h2&gt;
  
  
  🗺️ What's next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Short term&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Firefox ports for all three (MV3 is Firefox‑compatible now – 2‑week project per extension)&lt;/li&gt;
&lt;li&gt;Scheduled captures for ChainTrace (cron‑style auto‑export to Sheets)&lt;/li&gt;
&lt;li&gt;Weekly digest for TabCost ("your worst distraction this week was…")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Medium term&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Team plans for PR Focus (shared priority queue for engineering teams)&lt;/li&gt;
&lt;li&gt;CLI companion for ChainTrace (headless, scriptable, CI‑friendly)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🔗 Try them
&lt;/h2&gt;

&lt;p&gt;All extensions are live but have just launched. No fake numbers – just tools I built and am actively improving.&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;Install&lt;/th&gt;
&lt;th&gt;Buy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;💸 TabCost Pro&lt;/td&gt;
&lt;td&gt;&lt;a href="https://chrome.google.com/webstore/detail/tabcost-pro/oifegknejkfiibmfapdfcgemclgmmghm" rel="noopener noreferrer"&gt;Install free&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://projekta2.gumroad.com/l/tabcost-pro" rel="noopener noreferrer"&gt;$5 lifetime&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔍 PR Focus Pro&lt;/td&gt;
&lt;td&gt;&lt;a href="https://chromewebstore.google.com/detail/pr-focus-ai-pro/ememaiabefeojkccjclglcmbjmdpnaoe" rel="noopener noreferrer"&gt;Install free&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://projekta2.gumroad.com/l/PRFocusAIPro" rel="noopener noreferrer"&gt;$9.50 lifetime&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⛓️ ChainTrace&lt;/td&gt;
&lt;td&gt;&lt;a href="https://projekta2.github.io/chaintrace-landing/" rel="noopener noreferrer"&gt;Landing + demo&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://projekta2.gumroad.com/l/chaintrace-premium" rel="noopener noreferrer"&gt;$49 lifetime&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/projekta2" rel="noopener noreferrer"&gt;@projekta2&lt;/a&gt; – I answer every issue within 24 hours.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What would you have done differently? Happy to discuss architecture decisions, pricing, or anything else in the comments.&lt;/em&gt;&lt;/p&gt;

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