Chrome Extension (Manifest V3) Development
When to use
- Building a new Chrome/Edge extension from scratch
- Adding features to an existing MV3 extension
- Integrating LLM APIs into browser extensions
- Content-script DOM manipulation at page scale
Architecture (MV3 canonical layout)
my-extension/
├── manifest.json # MV3 manifest — permissions, CSP, entry points
├── background.js # Service worker (no DOM access, no window)
├── lib/ # Shared logic loaded via <script> in popup/options
│ ├── providers.js # API abstraction layer
│ └── ...
├── content/
│ ├── content.js # Injected into pages (has DOM, no chrome.tabs)
│ └── content.css # Scoped styles (prefix all classes!)
├── popup/ # Toolbar popup (own HTML/CSS/JS context)
├── options/ # Full settings page (chrome.runtime.openOptionsPage)
└── icons/ # 16/32/48/128 PNG
Key patterns
1. Manifest essentials
"manifest_version": 3— required for new submissions"permissions": ["storage", "contextMenus", "activeTab"]— storage.sync for settings"host_permissions": ["<all_urls>"]— needed for fetch() to arbitrary APIs from content scripts- Content scripts:
"run_at": "document_idle"(default) or"document_start"for early injection "web_accessible_resources"only if page needs to load extension assets
2. Provider abstraction (LLM calls from browser)
- OpenAI-compatible:
POST {baseUrl}/chat/completions,Authorization: Bearer {key}, SSE streaming viadata:lines - Anthropic:
POST {baseUrl}/v1/messages, headersx-api-key+anthropic-version: 2023-06-01+anthropic-dangerous-direct-browser-access: true(required for CORS from browser) - Google Gemini:
POST {baseUrl}/v1beta/models/{model}:generateContent?key={key}(key in URL, no auth header) - Google Translate (free engine, NOT an LLM):
GET https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl={target}&dt=t&q={text}— ~0.2s, no API key, no model. Offer it as a provider option alongside LLMs for raw speed. Response is nested JSON[[["translated","orig",...],...]]; concatenatej[0][*][0]. Still CORS-blocked from content scripts → route through service worker. Mark the provider withneedsModel: falseso the options UI hides the model/key fields. Seereferences/provider-endpoints.md§ "Free translation engine". - All three LLM types support streaming (SSE) — parse
data:lines, handle[DONE]sentinel for OpenAI - See
references/provider-endpoints.mdfor base URLs and model lists
2b. CRITICAL: CORS — route API calls through the service worker
Content scripts run in the PAGE's origin. fetch() from a content script to an LLM API is a cross-origin request subject to CORS. Most LLM providers (OpenAI, DashScope, OpenCode Zen, Groq, DeepSeek) do NOT send Access-Control-Allow-Origin headers → "Failed to fetch" error. host_permissions in the manifest do NOT bypass CORS for content scripts — they only grant permission for fetches from background/popup/options contexts.
The fix: proxy all LLM calls through the background service worker (which IS CORS-exempt):
background.js:importScripts('lib/providers.js')to load the API layerbackground.js:chrome.runtime.onConnectlistener on a named port (e.g.'ext-llm'), callsAIT.callLLM(), posts{type:'delta'|'done'|'error'}messages backproviders.js: detect context vialocation.protocol === 'chrome-extension:'— extension pages (popup/options) call directly; content scripts open a port and proxy through background- Streaming works over the port: background posts
{type:'delta', delta, full}per SSE chunk
This is the #1 cause of "Failed to fetch" in LLM extensions. Always architect for it from the start.
3. Content script DOM manipulation
- Namespace everything: prefix all classes/attributes (
ait-,myext-) to avoid page collisions - Block detection: walk DOM tree, skip SCRIPT/STYLE/PRE/CODE/TEXTAREA/SVG, collect visible text blocks
- Visibility check pitfall (causes "no paragraphs found" on React/Next.js SPAs): do NOT use
el.getClientRects().length === 0as a "hidden element" test. Elements withdisplay: contents(which React/Next.js apps use heavily for layout wrappers) return an EMPTY rect list even though they and their children are fully visible. A walker that treats them as hidden skips the ENTIRE subtree → zero blocks found → "nothing to translate" on an otherwise text-rich page. Instead test onlygetComputedStyle(el).display === 'none' || visibility === 'hidden'. Drop the getClientRects check entirely. - Card-splitting heuristic (fixes "spotty/gappy translation" on card UIs): a naive "container has text → translate it whole" rule grabs an entire project/product card (title + author + status all in one div) as a single block. The model then mangles the numbered output and the whole card gets dropped. Fix: count meaningful child elements (
meaningfulChildCount); if ≥ 2 children carry their own text, DESCEND and translate them individually instead of the container. Only treat a container as a leaf block when it has <2 meaningful children. Simple structures (sidebars, plain paragraphs) still translate as one block. - Parallel insertion: append translation
<div>inside the source element (preserves layout flow) - Restore: track translated elements via
data-*attribute, remove inserted nodes on restore - Display modes with instant switching (original / bilingual / translation-only): users want to toggle between "show original", "original + translation below", and "translation only" — and it must switch INSTANTLY with no re-translation (no extra API calls). The pattern: on first translate, move the element's original child nodes into a wrapper
<span class="ext-orig">(style itdisplay: contentsso it never affects layout) and append the translation<div>as a sibling, so BOTH are preserved in the DOM. Then a mode is just a CSSdisplaytoggle on those two nodes (ext-orighidden in translation-only; translation hidden in original-only). Store the mode asdata-ext-modeon the element. A globalapplyDisplayMode(mode)walks[data-ext-translated]and flips displays — O(n) DOM, zero network. Restore unwraps: move.ext-origchildren back out, remove the wrapper + translation node, clear the attributes. Key details: (a) do the wrapping ONCE and make insert idempotent (reuse the existing.ext-origand translation<div>on re-insert) so streaming callbacks and reconciliation don't re-wrap or duplicate; (b) backward-compat wrap:applyModemust handle elements translated by an OLDER content-script version that never created the wrapper — if switching to translation-only and no.ext-origexists, wrap the original child nodes on the fly (collect all children except the translation<div>, move them into a fresh wrapper). Without this, mode switching silently does nothing on already-translated pages and the user reports "it's still showing below". - Applying live setting changes to tabs with STALE content scripts (triple-path pattern): after an extension reload, already-open tabs keep running the OLD injected content script — so a popup→content message for a new feature (e.g.
ext:setMode) hits a listener that doesn't exist yet, and the user sees no change no matter how correct the new code is. Don't rely on the user closing/reopening tabs. Apply the change through THREE redundant paths from the popup: (1)chrome.tabs.sendMessage(works when the tab has the new script); (2)chrome.storage.sync.set+ achrome.storage.onChangedlistener in the content script (works across versions, since storage API is stable); (3)chrome.scripting.executeScript({ target: {tabId}, func, args })from the popup — injects the apply-function DIRECTLY into the tab, guaranteed to work regardless of which content-script version is loaded. Requires the"scripting"permission (plusactiveTab/host permissions). Path 3 should carry a self-contained copy of the DOM logic (no dependency on page globals). Also update the in-memory settings cache in the content script's storage listener so subsequently observed SPA content uses the new mode. - SPA support: MutationObserver on
document.bodywith debounce (1000-1500ms) for infinite scroll
4. Batch translation (token-efficient + fast)
- Number paragraphs
1. ... N., send as single prompt, parse numbered response - Batch size 10-12 paragraphs per request balances token efficiency vs. reliability
- On partial failure: retry missing numbers individually
- Worker-pool parallelism (critical for speed): split blocks into batches, then run N workers (N = concurrency setting, 4-6) pulling from a shared queue via
Promise.all. A naiveforloop withawaitinside is SERIAL even with a concurrency setting — the Translator's internal slot limiter only helps within a single call. The content script must spawn multiple concurrent batch calls itself. - Defaults: batchSize 8, concurrency 5. Free-tier models may 429 — let user dial down.
- Stream + paint line-by-line (biggest perceived-speed win): do NOT
awaitthe full batch response then render. Pass anonDeltainto the LLM call, buffer chunks, split on\n, and as each numbered line completes, parse it and immediately insert that one translation into the DOM (via anonLine(origIdx, text)callback). Result: first line appears in ~1s and the page fills in progressively, instead of a 5-10s blank wait then a sudden dump. Total wall-time is similar but perceived latency drops dramatically. MakeinsertTranslationidempotent (if element already translated, update its child node's textContent rather than appending a duplicate) so the streaming callback and the final reconciliation pass don't double-insert. - Keep a final reconciliation pass: after streaming, re-parse the full response for any lines the stream missed, then individually retry any still-missing numbers. Three layers (stream → full-parse → per-line retry) makes translation robust to flaky free-tier output.
5. Settings persistence
chrome.storage.sync— syncs across user's devices (8KB per item, 100KB total)chrome.storage.local— for large data (caches, model lists)- Pattern:
getSettings()returns Promise merging DEFAULTS + stored values - Per-provider credential storage (users WILL ask for this): when an extension supports multiple API providers, storing a single global
apiKey/baseUrl/modelmeans switching providers wipes the previous provider's credentials. The fix: store aproviderConfigsmap keyed by provider ID:
Implementation: (a)// chrome.storage.sync schema: // providerConfigs: { // alibaba: { apiKey: '***', baseUrl: '...', model: 'qwen3.6-flash' }, // openai: { apiKey: '***', baseUrl: '...', model: 'gpt-4o-mini' }, // }getSettings()mergesproviderConfigs[activeProvider]over defaults; (b) on provider switch in options UI, save current field values toproviderConfigs[oldProvider], then loadproviderConfigs[newProvider]into the form; (c) on any fieldchangeevent, also write through toproviderConfigs[currentProvider]; (d) legacy migration: ifproviderConfigsis empty but a globalapiKeyexists, copy it intoproviderConfigs[currentProvider]once and persist — so existing users don't lose their key on upgrade.
Pitfalls
- Service worker has no DOM/window — no
document, noalert(), noXMLHttpRequest. Usefetch(). - Content scripts can't use chrome.tabs — message the background worker instead
- CORS: Anthropic requires the
anthropic-dangerous-direct-browser-accessheader. Ollama needsOLLAMA_ORIGINS=*env var. LM Studio allows browser CORS by default. All other providers (OpenAI, DashScope, OpenCode Zen, Groq, DeepSeek) block browser CORS — route through service worker (see §2b). - CSS scoping: content script CSS is global to the page. Always prefix classes. Use high z-index (2147483646+) for overlays.
- Translation-text legibility (users will call out faint output): when rendering inserted translations, default to HIGH contrast, not subtle. Users consistently report "it's too faint / hard to see". Starting point that reads well on light pages: text color a strong dark accent (e.g.
#065f56teal, not a washed-out grey-green),font-weight: 500, font-size at LEAST1.0em(never shrink to 0.95em), a 4px left accent bar (a vertical gradient reads as more polished than flat), and a ~10% tinted background that deepens to ~16% on:hover. Provide a:hoverstate — it doubles as affordance and contrast check. For a "translation-only" mode, STRIP all decoration (border/background/padding/margin/color: inherit) via a[data-ext-mode="translation"] > .ext-translationrule so the text reads as the page's own native content rather than an obvious overlay. chrome.storage.synclimits: 8KB per key. Don't store model lists there — uselocalor keep in memory.- Popup/options are separate JS contexts — they don't share globals with content scripts. Use
chrome.storageor messaging. - MV3 service workers die after 30s idle — don't rely on persistent state in background.js variables.
- "Too slow" diagnosis ladder: (1) Is it serial? → worker-pool parallelism (§4). (2) Does the page sit blank then dump all at once? → not streaming; add line-by-line paint (§4). (3) Still slow first line? → that's server-side time-to-first-token (TTFT), NOT fixable client-side. The only levers are model choice (free tiers are slow;
deepseek-v4-flash-free/qwen-turboare the fast free options; paid flash tiers are 3-5× faster) and smaller batch size (8→4 makes the first line arrive sooner at the cost of more requests). Don't burn cycles optimizing client code for a server-bound latency. - Don't frame speed as "LLM = slow, translation API = fast" — it's a spectrum, and say so honestly. A user will (rightly) push back: commercial tools like Immersive Translate ALSO let you pick an LLM model, yet feel instant. The real speed levers, in order of impact: (a) engine/model choice — a dedicated translation engine (Google
gtx) or a small fast LLM (qwen-turbo,groq llama-3.1-8b-instant) vs a flagship/reasoning model; (b) concurrency (how many parallel requests); (c) server TTFT (free tiers queue, paid tiers don't); (d) streaming paint. When comparing your extension to a polished competitor, attribute the gap to these concrete factors, not to a false LLM/non-LLM binary — and never overclaim "competitor X doesn't use LLMs" without evidence. Offer the fast engine as a choice alongside the LLM, not a replacement. - Reasoning models are the WRONG choice for translation (and most bulk/simple tasks): models like
qwen3.8-max-preview,*-reasoner,o-series, or anything with a "thinking" mode default to a huge reasoning budget (e.g. qwen3.8-max-preview defaults toxhigheffort with a 131,072-token thinking budget). Every request "thinks" before answering → catastrophic first-token latency AND the reasoning tokens are billed, draining credits/quota fast. Translation is a understand-and-substitute task that needs NO reasoning. If a user reports slowness while on a flagship reasoning model, the single highest-impact fix is switching to a small non-reasoning model (qwen-turbo,qwen-plus,qwen-flash). Rule of thumb for translation: small fast model ≈ flagship quality at ~1/30 the cost and far lower latency. Reserve reasoning models for genuinely hard multi-step work, never bulk text transforms. - Code reload requires remove+re-add, not just refresh: After editing extension files, the 🔄 refresh button on
chrome://extensionsoften does NOT pick up content script changes reliably. The reliable sequence: (1) Remove the extension, (2) Load unpacked again, (3) Close all tabs that had the old content script, (4) Open a fresh tab. Existing tabs retain the old injected content script even after extension reload — this is the #1 cause of "I fixed the code but the error persists" during development. - Hermes output redaction corrupts credential-field code: The Hermes runtime redacts strings matching secret patterns (e.g.
apiKey: *** in BOTH tool output display ANDpatchtoolnew_stringinputs. When youpatcha file with code likeapiKey: *** the tool may silently writeapiKey: *** into the actual file, producing a SyntaxError. Theread_fileoutput also shows***so you can't tell if the file is actually broken or just display-redacted. **Diagnosis**: runnode --check— if it reportsUnexpected token '**'the file is genuinely corrupted. **Fix**: write a Python script that builds the correct line viabase64.b64decode(...)` and writes it by line number, bypassing the redaction filter entirely. Example:
Alwaysimport base64 lines = open(f, encoding='utf-8').readlines() # base64 of " apiKey: ***" correct = base64.b64decode('ICAgIGFwaUtleTogJCgnYXBpS2V5JykudmFsdWUudHJpbSgpLA==').decode() lines[89] = correct + '\n' open(f, 'w', encoding='utf-8').writelines(lines)node --checkafter any patch touching credential fields. This applies to ANY code withapiKey,api_key,secret,tokenassignment patterns.
Verification checklist
node --checkevery .js file (syntax)JSON.parsethe manifest (valid JSON)- Load unpacked at
chrome://extensionswith Developer mode ON - Check service worker console (click "Inspect views: service worker")
- Check content script console (page DevTools → console, select extension context)
- Test on a real page with varied DOM (news article, SPA, table-heavy page)
Support files
references/provider-endpoints.md— LLM provider base URLs, auth patterns, model liststemplates/manifest.json— starter MV3 manifest