Six weeks and three platform rewrites later
The context trap almost killed the product
My first instinct was a backend dashboard — scrape rate limits, push to a server, let users check a separate tab. Three days into the API design I caught myself: why would anyone context-switch to a dashboard when they're staring at the rate limit error inside Claude? The fix was obvious once I stopped overthinking: inject a slim bar directly above the input box. Always visible, zero tab switching, problem solved where it actually happens.
Lesson learned: solve the friction in the exact context it appears. Every extra step is a failure point.
MV3 is hostile to rapid iteration
Chrome's Manifest V3 constraints bit me in three specific ways:
CSP kills inline everything. Every onclick="handler()" in my popup HTML failed silently. The fix: addEventListener wired after DOMContentLoaded for every single interaction. Took a day to trace because the errors don't surface in the console the way you'd expect.
Service workers die on you. Background scripts are now ephemeral — Chrome kills them between messages. Any state held in memory vanishes. Everything persistent must go through chrome.storage.local. I learned this when users reported intermittent "limit not updating" bugs that I couldn't reproduce locally.
Message channels have a contract. chrome.runtime.onMessage listeners must return true if you'll call sendResponse asynchronously, false (or nothing) if you won't. Get this wrong and you get "message channel closed before response was received" — an error that appears randomly and leaves no stack trace.
// Correct async pattern
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'GET_USAGE') {
fetchUsage().then(data => sendResponse(data))
return true // critical: tells Chrome we'll respond async
}
return false
})Selector rot is inevitable — plan for it
Every platform updates its frontend weekly. ChatGPT broke my content script three days post-launch. The only sustainable pattern is defensive selector arrays with explicit fallbacks:
const INPUT_SELECTORS = [
'#prompt-textarea', // primary
'[data-id="prompt-textarea"]', // fallback 1
'div[contenteditable="true"]', // fallback 2
'textarea[placeholder*="Message"]' // fallback 3
]
function findInput() {
for (const selector of INPUT_SELECTORS) {
const el = document.querySelector(selector)
if (el) return el
}
return null
}Never trust a single selector. Always null-check before DOM operations. I now ship with 3-4 fallbacks per platform and a background job that logs selector failures so I know when to update.
I built a feature zero people used
Spent two weeks on a "token optimization tips" panel in the popup — collapsible, categorized, pretty. Total interactions in week one: zero. Not a single piece of feedback mentioned it.
Meanwhile, the #1 request: "show me weekly spend across all platforms." I had per-conversation cost tracking but no rollup. Shipped the weekly summary in v1.2 — immediate engagement spike.
The lesson isn't "do user research" (I did). It's that users can't articulate what they need until they're using the product. The tips panel sounded useful in theory. The weekly view revealed itself from actual behavior.
Ship the minimum. Watch what they reach for. Build that.
Notifications took three rewrites
First attempt: chrome.notifications.create() for every limit warning. Result — notification spam during long coding sessions, users disabled permissions.
Second: in-page toast system. Better, but z-index fights with platform modals and the toasts got buried.
Third (current): a persistent but collapsible banner injected at the top of the chat viewport, color-coded by severity (yellow at 70%, orange at 90%, red at 95%). Dismissible per-session, respects prefers-reduced-motion. Finally zero complaints.
The extension is live as TokenPulse if anyone wants to poke at the implementation. Happy to share the manifest config or the platform detection logic if it saves someone the MV3 learning curve.