May 19, 2026 – 5 min read
Building an AI Chrome Extension to 10,000 Users: Manifest V3 Lessons from Upwex
In 2023 I co-founded Upwex – an AI copilot for Upwork freelancers that lives inside a Chrome extension. Today it has more than 10,000 users. This post is the architecture write-up I wish I'd had on day one: not the happy path from the docs, but the walls we actually hit with Manifest V3, AI APIs, and the Chrome Web Store review process – and how we got around each one.
The architecture at a glance
An AI extension is really four applications pretending to be one:
- Content scripts injected into the host site (in our case, Upwork pages), reading the DOM and rendering UI.
- A service worker – the MV3 replacement for background pages – routing messages, holding no state.
- A backend API that owns authentication, billing, and every single AI call.
- A companion web app (React, in our case) for subscription management and analytics dashboards.
The single most important decision we made: no AI calls from the extension itself. Every LLM request goes through our backend. Keys never ship to the client, we can cache and rate-limit centrally, and we can switch model providers without shipping an extension update – which matters, because a Chrome Web Store review can take days.
Service workers are not background pages
The classic MV2 pattern – keep state in a long-lived background page – is gone. An MV3 service worker is killed after ~30 seconds of inactivity, and it will be killed mid-flight if you're careless.
What survived contact with production:
- Treat the service worker as a stateless router. Anything that must persist goes to
chrome.storage.sessionorchrome.storage.localimmediately. If you find yourself writinglet cache = {}at the top level of the worker, you've already written a bug – it just hasn't fired yet. - Long AI generations don't fit in a worker's lifetime. A proposal generation can take 20–40 seconds. We moved to a job model: the worker fires a request to the backend, the backend responds with a job id instantly, and the content script polls (or receives a push) for the result. The worker being killed in between costs nothing.
- Alarms over timers.
setTimeoutbeyond the worker's lifetime silently never fires.chrome.alarmsis the only scheduling primitive you can trust.
Content-script isolation will surprise you exactly once
Content scripts run in an isolated world: they see the page's DOM but not its JavaScript. On a heavily client-side-rendered site like Upwork this bites in two ways:
- The data you want is often in the page's JS state, not the DOM. You either parse the DOM defensively or inject a script into the page's main world and message the data out. We do both, depending on the surface.
- SPAs don't reload pages. Your content script boots once; the site then navigates ten times without a single page load. We ended up with a small router of our own – a
MutationObserver-driven detector that answers one question: "which Upwork screen is the user on now?" – and mounts/unmounts our UI accordingly.
Budget real time for this. Selector-based scraping of a site you don't control is a maintenance treadmill; ours is wrapped in a single data-access layer so that when Upwork ships a redesign, the fix lands in one module, not thirty.
Surviving Chrome Web Store review
Store review is the part nobody architecture-diagrams, and it's the part that can freeze your release train for a week. What keeps our reviews passing:
- Request the minimum permissions and justify every one. Broad host permissions (
<all_urls>) invite rejection. We scope to the domains we actually touch. - A privacy policy that matches reality. Reviewers check. If your extension sends page content to an AI backend, say so, plainly.
- No remote code. MV3 bans it outright, and "clever" loaders get extensions pulled. All logic ships in the bundle; only data comes from the network.
- Keep a plain-text reviewer note explaining what the extension does and which account they can test with. It shortens reviews measurably.
The deeper implication of slow reviews is architectural: move everything volatile server-side. Prompts, pricing logic, feature flags, model choice – all of it lives in our backend and can change in minutes. The extension is a thin, stable shell around a fast-moving service.
AI cost control is a product feature
LLM tokens are your margin. Three things kept Upwex's unit economics sane:
- Central caching. Identical or near-identical requests (same job post, same analysis type) hit a cache, not the model.
- Model tiering. Cheap, fast models for classification and scoring; the expensive model only for the user-facing generation that justifies it.
- Hard ceilings per plan. Every subscription tier has explicit AI quotas enforced at the API layer – not in the extension, where enforcement can be bypassed by anyone with DevTools open.
What I'd tell you before you start
- Design the extension as a thin client from day one. Store review latency makes fat clients expensive to iterate.
- Put a single abstraction over the host site's DOM. It will change; only the abstraction should care.
- Accept that the service worker owns nothing. Storage owns state, the backend owns logic, the worker just routes.
- Ship crash and error telemetry from the first beta. Extension bugs reproduce on machines you'll never see.
Building an extension like this end to end – architecture, MV3 constraints, AI integration, store review – is exactly the work I do for clients. If you're planning one, the Chrome extension development page describes how an engagement runs, or just email me at sinclar96@gmail.com.
Building something in this stack?
Get in touch ↗