██████╗ ██╗ ██╗ ███████╗██╗ ██╗████████╗███████╗
██╔══██╗██║ ██╔╝ ██╔════╝╚██╗██╔╝╚══██╔══╝╚════██║
██████╔╝█████╔╝ █████╗ ╚███╔╝ ██║ ██╔╝
██╔═══╝ ██╔═██╗ ██╔══╝ ██╔██╗ ██║ ██╔╝
██║ ██║ ██╗ ███████╗██╔╝ ██╗ ██║ ██║
╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝
by @projekta2 · 3 live extensions · $0 in VC · ~6 months
I had never written a Chrome extension in my life.
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.
This article is a technical postmortem. 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.
Reading time: ~12 minutes
🛠️ The three products
Opportunity cost tracker for idle browser tabs. Set your hourly rate. The popup shows how much your inactive tabs have cost you today. Freemium · $5 lifetime Pro.
AI assistant for GitHub pull requests. Summarises diffs, scores risk, helps you prioritise your review queue. Freemium · $9.50 lifetime Pro.
⛓️ ChainTrace — Landing + demo
Supply chain data extraction. Auto‑detects tables on Alibaba, SAP Ariba, Shopify or internal ERPs and exports to Google Sheets in one click. Freemium · $49 lifetime Premium.
All three are live but brand new. No user numbers to boast about yet – that's the honest state.
Part 1: Technical decisions that worked
⚙️ MV3 is not the enemy
When I started, Manifest V3 had a bad reputation. "Service workers terminate unexpectedly." "Too restrictive."
After building three extensions on it, I found that starting fresh on MV3 is fine. The constraints force a cleaner architecture.
The thing that bit me most: service workers terminate after ~30 seconds of inactivity. You must design around it.
TabCost's cost engine runs on a 1‑minute chrome.alarms tick. This is the pattern that works:
// background.js — cost engine driven by alarms
chrome.alarms.create("minuteTicker", { periodInMinutes: 1 });
chrome.alarms.create("heartbeat", { periodInMinutes: 5 });
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === "minuteTicker") {
await checkMidnightRotation();
await calculateOpportunityCost();
}
if (alarm.name === "heartbeat") {
await ensureAlarms(); // recreates minuteTicker if it went missing
const stored = await chrome.storage.local.get('lastCalculationTimestamp');
const last = stored.lastCalculationTimestamp;
if (last && (Date.now() - last) > 120_000) {
await calculateOpportunityCost(); // fallback recalc
}
}
});
💡 The heartbeat alarm is critical. Without it,
minuteTickercan disappear after Chrome restarts. I only discovered this from a bug report I couldn't reproduce locally.
The cost calculation uses an elapsed‑time delta model, capped at 15 minutes:
async function calculateOpportunityCost() {
const now = Date.now();
const stored = await chrome.storage.local.get('lastCalculationTimestamp');
const lastTimestamp = stored.lastCalculationTimestamp;
if (!lastTimestamp) {
await chrome.storage.local.set({ lastCalculationTimestamp: now });
return;
}
// Cap at 15 min – if Chrome slept for 2h, don't charge 120 minutes at once
const elapsedMinutes = Math.min((now - lastTimestamp) / 60_000, 15);
if (elapsedMinutes < 0.5) return;
await chrome.storage.local.set({ lastCalculationTimestamp: now });
const rate = config.hourlyRate || 0;
const grace = Math.max(1, config.graceMinutes);
const impact = Math.min(50, Math.max(10, config.impactPercent)) / 100;
const tabs = await chrome.tabs.query({});
let addedCost = 0;
for (const tab of tabs) {
if (isTabIgnored(tab)) continue;
const lastTime = tabLastInteraction[tab.id] || now;
const inactiveMinutes = (now - lastTime) / 60_000;
if (inactiveMinutes > grace) {
addedCost += (rate / 60) * impact * elapsedMinutes;
}
}
const prev = (await chrome.storage.local.get('dailyCost')).dailyCost || 0;
await chrome.storage.local.set({ dailyCost: prev + addedCost });
}
The 15‑minute cap is conservative and always in the user's favour – no one gets charged for hours when the browser was suspended.
🔑 Gumroad licensing: the implementation nobody writes about
Most tutorials skip licensing entirely. Here's what actually works.
Setup: user buys on Gumroad → enters license key in extension → extension verifies with Gumroad API → stores a fingerprinted result locally.
The fingerprint stops casual key‑sharing:
// Deterministic fingerprint: license key + a secret salt
const _LS = "TC_fp_v1_prod"; // never changes between versions
function computeLicenseFingerprint(key) {
let h = 5381;
const s = key + _LS;
for (let i = 0; i < s.length; i++) {
h = ((h << 5) + h) ^ s.charCodeAt(i);
h = h >>> 0;
}
return h.toString(36);
}
// On activation – verify with Gumroad, then store key + fingerprint
async function verifyGumroadLicense(licenseKey) {
const response = await fetch("https://api.gumroad.com/v2/licenses/verify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `product_id=${PRODUCT_ID}&license_key=${encodeURIComponent(licenseKey)}`
});
const result = await response.json();
return result.success === true && !result.uses_per_license_exceeded;
}
if (isValid) {
const fingerprint = computeLicenseFingerprint(licenseKey);
await chrome.storage.local.set({
isPro: true,
licenseKey,
licenseFingerprint: fingerprint
});
}
On every Chrome startup, verify the stored fingerprint hasn't been tampered with:
async function verifyStoredLicense() {
const { isPro, licenseKey, licenseFingerprint } =
await chrome.storage.local.get(['isPro', 'licenseKey', 'licenseFingerprint']);
if (!isPro) return;
if (!licenseKey || !licenseFingerprint) {
await chrome.storage.local.set({ isPro: false });
return;
}
const expected = computeLicenseFingerprint(licenseKey);
if (expected !== licenseFingerprint) {
await chrome.storage.local.set({
isPro: false,
licenseKey: null,
licenseFingerprint: null
});
}
}
⚠️ This won't stop a determined attacker – they can find
_LSin the source. But it stops 95% of casual key‑sharing. For a $5 product, that's the right trade‑off.
What I'd add if starting again: a daily server‑side re‑validation call to Gumroad's /licenses/verify endpoint. I have it stubbed but not fully wired.
📊 GraphQL vs REST for GitHub data (PR Focus Pro)
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.
I went hybrid: GraphQL for the bulk fetch, REST for actions (posting reviews, closing PRs).
// One query – everything needed for a risk score
const GET_PRS_WITH_CONTEXT = `
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 } }
}
}
}
}
`;
The risk score is computed client‑side:
function computeRiskScore(pr) {
let score = 0;
// CI status – 40 points max
if (pr.ciState === 'FAILURE') score += 40;
else if (pr.ciState === 'PENDING') score += 15;
// PR age in days – 20 points max
const ageDays = (Date.now() - new Date(pr.createdAt)) / 86_400_000;
score += Math.min(20, ageDays * 2);
// Lines changed – 20 points max
const linesChanged = pr.additions + pr.deletions;
score += Math.min(20, linesChanged / 50);
// Sensitive areas (auth, security, db, infra) – 20 points max
const sensitivePattern = /auth|security|payment|migration|database|infra/i;
const isSensitive = sensitivePattern.test(pr.title) ||
pr.labels.some(l => sensitivePattern.test(l.name));
if (isSensitive) score += 20;
return Math.min(100, Math.round(score));
}
Simple, explainable, zero‑latency once the data is fetched. Users see why a PR scores high – the breakdown is shown in the UI – which reduces support tickets.
💾 Zero‑backend storage: 365 days in chrome.storage.local
TabCost stores a full year of daily cost history. No server, no database. Just chrome.storage.local.
chrome.storage.local has a 10MB quota. One year of daily entries is about 15KB – negligible.
Midnight rotation pattern:
async function checkMidnightRotation() {
const { lastSavedDate, dailyCost, dailyHistory } =
await chrome.storage.local.get(['lastSavedDate', 'dailyCost', 'dailyHistory']);
const todayStr = new Date().toISOString().split('T')[0];
if (lastSavedDate === todayStr) return;
const history = dailyHistory || [];
history.push({ date: lastSavedDate, cost: dailyCost || 0 });
// Free tier: 90 days max. Pro: 365 days.
const maxHistory = config.isPro ? 365 : 90;
if (history.length > maxHistory) history.shift();
await chrome.storage.local.set({
dailyHistory: history,
dailyCost: 0,
lastSavedDate: todayStr,
notifiedToday: false
});
}
Called on every minuteTicker tick. Atomic – the write either succeeds or it doesn't. Works correctly after a Chrome restart because lastSavedDate persists.
Part 2: Product and pricing decisions (honest lessons)
💰 One‑time pricing, no subscriptions
All three use one‑time pricing ($5, $9.50, $49). No subscriptions.
Subscriptions create pressure to retain users regardless of ongoing value. One‑time pricing means users feel they own the tool. That ownership produces better reviews, more patience with bugs, and – counterintuitively – more willingness to recommend.
🆓 Freemium is harder than expected
Mistake with TabCost: 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.
What ChainTrace does differently: free is capped at 50 captures/month and 500 rows. Pro is unlimited. The limit hits you while using the tool, not as a locked feature you might never discover.
💡 Lesson: freemium gates that activate during normal use convert better than gates on features users may not discover.
"You've used 47 of 50 captures this month" is a better nudge than "Upgrade to access the history chart."
| Approach | Gate type | Conversion signal |
|---|---|---|
| TabCost (mistake) | Feature locked behind Pro | User may never discover it |
| ChainTrace (better) | Usage cap hit mid-workflow | User feels the limit in real time |
🎯 Niche beats broad
ChainTrace solves a specific operational pain: procurement professionals copy‑pasting from supplier portals every week. They understand the value immediately. No convincing needed.
TabCost's target (freelancers wanting to be more focused) requires behaviour change first. That's harder.
Implication: a tool that solves a daily operational pain point outperforms a productivity tool that needs behaviour change to deliver value.
📄 The README as a marketing asset
TabCost's source README is 570 lines. Problem statement, feature table, architecture diagram, code walkthrough, pricing comparison, roadmap, contributing guide.
The structure that works, in order:
- Problem statement in prose – specifics matter
- The output – the number or thing users actually see
- Feature table: free vs. paid, honest about limits
- Architecture block – proves technical credibility
- Roadmap – shows the product isn't abandoned
- Contributing section – creates community
Part 3: What I'd do differently (with hindsight)
1. Validate before building
I built TabCost before posting anywhere. Demand existed – luck, not skill. Next time I'll post to HackerNews or a Discord community first.
2. Tighter free tier
TabCost free gives away too much. Should be 50% of value, not 80%. Discomfort of the limit drives conversions.
3. Launch is a separate project
I shipped TabCost in January. I "launched" it in March. Two months of zero users. Right sequence:
| Timing | Action |
|---|---|
| –2 weeks | Landing page live |
| Day 0 | Dev.to article |
| Day 0 + 24h | HackerNews "Show HN" |
| Day 1–2 | 2–3 targeted subreddits |
| Day 2–3 | ProductHunt (after initial traction) |
4. Ask for reviews at the first win
I waited 3 months. The right moment is when the user gets their first result – first export, first PR summary, first "you saved $X today". Sentiment is highest exactly then.
🗺️ What's next
Short term
- Firefox ports for all three (MV3 is Firefox‑compatible now – 2‑week project per extension)
- Scheduled captures for ChainTrace (cron‑style auto‑export to Sheets)
- Weekly digest for TabCost ("your worst distraction this week was…")
Medium term
- Team plans for PR Focus (shared priority queue for engineering teams)
- CLI companion for ChainTrace (headless, scriptable, CI‑friendly)
🔗 Try them
All extensions are live but have just launched. No fake numbers – just tools I built and am actively improving.
| Extension | Install | Buy |
|---|---|---|
| 💸 TabCost Pro | Install free | $5 lifetime |
| 🔍 PR Focus Pro | Install free | $9.50 lifetime |
| ⛓️ ChainTrace | Landing + demo | $49 lifetime |
GitHub: @projekta2 – I answer every issue within 24 hours.
What would you have done differently? Happy to discuss architecture decisions, pricing, or anything else in the comments.
Top comments (0)