
Wxt Browser Extensions
- 883 installs
- 186 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
wxt-browser-extensions is a rule-set skill that gives coding agents WXT browser extension best practices with impact-tagged TypeScript examples for developers building high-performance modern extensions.
About
wxt-browser-extensions is a dot-skills rule collection for the WXT browser extension framework. Each rule card includes an impact level (CRITICAL through LOW), quantified impact descriptions such as 2-10× improvements, tags, incorrect anti-pattern TypeScript snippets, and corrected implementations with guidance on when not to apply the pattern. Developers reach for wxt-browser-extensions when agents generate or review WXT extension code for Chrome, Firefox, or other targets and need performance-safe patterns around messaging, storage, content scripts, and build configuration. The skill prevents common extension pitfalls that cause data loss or runtime regressions during iterative agent-assisted development.
- 49 rules across 8 categories prioritized by impact
- Critical focus on service worker lifecycle and content script injection
- Updated for WXT v0.20+ with #imports and current API patterns
- Incorrect vs correct code examples for every rule
- Designed specifically for AI agents and LLMs to consume
Wxt Browser Extensions by the numbers
- 883 all-time installs (skills.sh)
- +28 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #1,194 of 16,565 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill wxt-browser-extensionsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 883 |
|---|---|
| repo stars | ★ 186 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do you write high-performance WXT browser extensions?
Give their coding agent a comprehensive rule set for writing high-performance, modern WXT browser extensions.
Who is it for?
Frontend developers building WXT-based Chrome or Firefox extensions with agent-generated TypeScript who want guardrailed patterns.
Skip if: Developers building plain webpack extensions without WXT or teams that only need manifest V2 legacy snippets.
When should I use this skill?
The user asks to scaffold, review, or optimize a WXT browser extension in TypeScript.
What you get
Impact-ranked WXT rule cards, corrected TypeScript snippets, and anti-pattern fixes
- rule-guided TypeScript patches
- anti-pattern fixes
- extension architecture guidance
By the numbers
- Rules use 6 impact tiers from CRITICAL through LOW
- Each rule card includes incorrect and correct TypeScript code blocks
Files
Community WXT Browser Extensions Best Practices
Comprehensive performance optimization guide for WXT browser extension development. Contains 49 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation. Updated for WXT v0.20+.
When to Apply
Reference these guidelines when:
- Writing new WXT browser extension code
- Implementing service worker background scripts
- Injecting content scripts into web pages
- Setting up messaging between extension contexts
- Configuring manifest permissions and resources
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Service Worker Lifecycle | CRITICAL | svc- |
| 2 | Content Script Injection | CRITICAL | inject- |
| 3 | Messaging Architecture | HIGH | msg- |
| 4 | Storage Patterns | HIGH | store- |
| 5 | Bundle Optimization | MEDIUM-HIGH | bundle- |
| 6 | Manifest Configuration | MEDIUM | manifest- |
| 7 | UI Performance | MEDIUM | ui- |
| 8 | TypeScript Patterns | LOW-MEDIUM | ts- |
Quick Reference
1. Service Worker Lifecycle (CRITICAL)
- `svc-register-listeners-synchronously` - Register listeners synchronously to prevent missed events
- `svc-avoid-global-state` - Use storage instead of in-memory state
- `svc-keep-alive-patterns` - Keep service worker alive for long operations
- `svc-handle-install-update` - Handle install and update lifecycle events
- `svc-offscreen-documents` - Use offscreen documents for DOM operations
- `svc-declarative-net-request` - Use declarative rules for network blocking
2. Content Script Injection (CRITICAL)
- `inject-use-main-function` - Place runtime code inside main() function
- `inject-choose-correct-world` - Select ISOLATED or MAIN world appropriately
- `inject-run-at-timing` - Configure appropriate runAt timing
- `inject-use-ctx-invalidated` - Handle context invalidation on update
- `inject-dynamic-registration` - Use runtime registration for conditional injection
- `inject-all-frames` - Configure allFrames for iframe handling
- `inject-spa-navigation` - Handle SPA navigation with wxt:locationchange
3. Messaging Architecture (HIGH)
- `msg-type-safe-messaging` - Use @webext-core/messaging for type-safe protocols
- `msg-return-true-for-async` - Return true for async message handlers (raw API)
- `msg-use-tabs-sendmessage` - Use tabs.sendMessage for content scripts
- `msg-use-ports-for-streams` - Use ports for streaming communication
- `msg-handle-no-receiver` - Handle missing message receivers
- `msg-avoid-circular-messages` - Prevent circular message loops
4. Storage Patterns (HIGH)
- `store-use-define-item` - Use storage.defineItem for type-safe access
- `store-choose-storage-area` - Select appropriate storage area
- `store-batch-operations` - Group related data into single defineItem
- `store-watch-for-changes` - Use watch() for reactive updates
- `store-handle-quota-errors` - Handle storage quota errors
- `store-versioned-migrations` - Use versioning for schema migrations
5. Bundle Optimization (MEDIUM-HIGH)
- `bundle-split-entrypoints` - Split code by entrypoint
- `bundle-analyze-size` - Analyze and monitor bundle size
- `bundle-tree-shake-icons` - Use direct imports for icon libraries
- `bundle-externalize-wasm` - Load WASM dynamically
- `bundle-minify-content-scripts` - Minimize content script size
6. Manifest Configuration (MEDIUM)
- `manifest-minimal-permissions` - Request minimal permissions
- `manifest-use-optional-permissions` - Use optional permissions progressively
- `manifest-web-accessible-resources` - Scope web accessible resources
- `manifest-content-security-policy` - Configure CSP correctly
- `manifest-cross-browser-compatibility` - Support multiple browsers
7. UI Performance (MEDIUM)
- `ui-use-shadow-dom` - Use Shadow DOM for injected UI
- `ui-defer-rendering` - Defer popup rendering until needed
- `ui-cleanup-on-unmount` - Clean up UI on unmount
- `ui-sidepanel-persistence` - Preserve sidepanel state
- `ui-position-fixed-iframe` - Use iframe for complex UI
- `ui-avoid-layout-thrashing` - Batch DOM reads and writes
8. TypeScript Patterns (LOW-MEDIUM)
- `ts-use-imports-module` - Use #imports virtual module and auto-imports
- `ts-use-browser-not-chrome` - Use browser namespace over chrome
- `ts-type-entrypoint-options` - Type entrypoint options explicitly
- `ts-augment-browser-types` - Augment types for missing APIs
- `ts-strict-null-checks` - Enable strict null checks
- `ts-import-meta-env` - Use import.meta for build info
- `ts-avoid-any` - Avoid any type in handlers
- `ts-path-aliases` - Use path aliases for imports
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Rule Title Here
Brief explanation (1-3 sentences) of WHY this matters. Focus on performance implications or the problem being solved.
Incorrect (description of what's wrong):
// Code showing the anti-pattern
// Comment explaining the cost/problemCorrect (description of what's right):
// Code showing the correct pattern
// Comment explaining the benefit (optional, fewer comments than incorrect)When NOT to use this pattern:
- Exception case 1
- Exception case 2
Note: Additional context or alternatives if needed.
Reference: Reference Title
{
"version": "1.1.5",
"organization": "Community",
"technology": "WXT Browser Extensions",
"date": "February 2026",
"abstract": "Comprehensive performance optimization guide for WXT browser extension development, designed for AI agents and LLMs. Contains 49 rules across 8 categories, prioritized by impact from critical (service worker lifecycle, content script injection) to incremental (TypeScript patterns). Updated for WXT v0.20+ with #imports, removed polyfill, and current API patterns. Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.",
"references": [
"https://wxt.dev",
"https://wxt.dev/guide",
"https://developer.chrome.com/docs/extensions/mv3/",
"https://webext-core.aklinker1.io/",
"https://developer.chrome.com/docs/extensions/reference/api/",
"https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions",
"https://wxt.dev/guide/resources/upgrading"
]
}
WXT Browser Extensions Best Practices Skill
A comprehensive performance optimization guide for WXT browser extension development, containing 49 rules across 8 categories. Updated for WXT v0.20+.
Overview/Structure
wxt-browser-extensions/
├── SKILL.md # Entry point with quick reference
├── README.md # This file
├── metadata.json # Version, org, references
├── references/
│ ├── _sections.md # Category definitions
│ ├── svc-*.md # Service Worker Lifecycle (6 rules)
│ ├── inject-*.md # Content Script Injection (7 rules)
│ ├── msg-*.md # Messaging Architecture (6 rules)
│ ├── store-*.md # Storage Patterns (6 rules)
│ ├── bundle-*.md # Bundle Optimization (5 rules)
│ ├── manifest-*.md # Manifest Configuration (5 rules)
│ ├── ui-*.md # UI Performance (6 rules)
│ └── ts-*.md # TypeScript Patterns (8 rules)
└── assets/
└── templates/
└── _template.md # Rule template for extensionsGetting Started
Installation
pnpm installBuilding
pnpm buildValidation
pnpm validateCreating a New Rule
1. Choose the appropriate category based on the rule's focus area 2. Create a new file in references/ with the category prefix 3. Use the template in assets/templates/_template.md 4. Add an entry to SKILL.md under the appropriate category
Category Prefixes
| Prefix | Category | Impact |
|---|---|---|
svc- | Service Worker Lifecycle | CRITICAL |
inject- | Content Script Injection | CRITICAL |
msg- | Messaging Architecture | HIGH |
store- | Storage Patterns | HIGH |
bundle- | Bundle Optimization | MEDIUM-HIGH |
manifest- | Manifest Configuration | MEDIUM |
ui- | UI Performance | MEDIUM |
ts- | TypeScript Patterns | LOW-MEDIUM |
Rule File Structure
Each rule file must include:
---
title: Rule Title (imperative verb form)
impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW-MEDIUM|LOW
impactDescription: Quantified impact (e.g., "2-10× improvement")
tags: category-prefix, technique, related-concepts
---
## Rule Title
Brief explanation (1-3 sentences) of WHY this matters.
**Incorrect (description of problem):**
\`\`\`typescript
// Code showing anti-pattern
\`\`\`
**Correct (description of solution):**
\`\`\`typescript
// Code showing correct pattern
\`\`\`
Reference: [Source](https://example.com)File Naming Convention
Files follow the pattern: {prefix}-{description}.md
prefix: Category identifier (3-8 chars)description: Kebab-case description of the rule
Examples:
svc-register-listeners-synchronously.mdinject-use-main-function.mdmsg-return-true-for-async.md
Impact Levels
| Level | Description |
|---|---|
| CRITICAL | Major performance issues, data loss risks, or broken functionality |
| HIGH | Significant performance impact or reliability issues |
| MEDIUM-HIGH | Noticeable performance or maintenance benefits |
| MEDIUM | Moderate improvements or best practices |
| LOW-MEDIUM | Minor optimizations or code quality improvements |
| LOW | Micro-optimizations or stylistic preferences |
Scripts
| Command | Description |
|---|---|
pnpm validate | Validate skill structure and content |
pnpm build | Generate AGENTS.md from rules |
Contributing
1. Follow the rule template exactly 2. Ensure impact descriptions are quantified 3. Include both incorrect and correct code examples 4. Use production-realistic code (no foo/bar/baz) 5. Run validation before submitting
Acknowledgments
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Service Worker Lifecycle (svc)
Impact: CRITICAL Description: MV3 service workers are ephemeral and can terminate at any time. Improper event registration and state management causes missed events and broken extension functionality.
2. Content Script Injection (inject)
Impact: CRITICAL Description: Content script timing, world selection, and DOM access patterns determine whether extensions interact correctly with web pages. Wrong patterns cause race conditions and security issues.
3. Messaging Architecture (msg)
Impact: HIGH Description: Cross-context communication between background, content scripts, and UI is the backbone of extensions. Type-safe async patterns prevent data loss and silent failures.
4. Storage Patterns (store)
Impact: HIGH Description: Type-safe storage with proper area selection and watch patterns prevents data corruption, race conditions, and quota issues in extension state management.
5. Bundle Optimization (bundle)
Impact: MEDIUM-HIGH Description: Extension package size affects Chrome Web Store install rates and startup time. Tree-shaking, code splitting, and dependency optimization reduce overhead.
6. Manifest Configuration (manifest)
Impact: MEDIUM Description: Proper permission scoping, host permissions, and web accessible resource declarations affect security review outcomes and extension capabilities.
7. UI Performance (ui)
Impact: MEDIUM Description: Shadow DOM isolation, efficient rendering, and proper lifecycle management in popups, sidepanels, and options pages affect user experience.
8. TypeScript Patterns (ts)
Impact: LOW-MEDIUM Description: WXT-specific TypeScript patterns, API usage, and type safety improve code maintainability and catch errors at build time.
Analyze and Monitor Bundle Size
Use bundle analysis to identify unexpectedly large dependencies. Many npm packages include unnecessary code that dramatically increases extension size.
Incorrect (blind dependency usage):
// Unknowingly importing 500KB+ from lodash barrel export
import { debounce } from 'lodash'
// Using a heavy charting library in a popup that only shows a number
import Chart from 'chart.js/auto'Correct (analyze with wxt build --analyze):
# Analyze bundle size for each entrypoint
wxt build --analyze
# Open interactive treemap in browser
wxt build --analyze-open// package.json
{
"scripts": {
"build": "wxt build",
"analyze": "wxt build --analyze-open"
}
}Use lightweight alternatives:
// Instead of lodash (500KB+)
import debounce from 'lodash.debounce' // 2KB
// Instead of moment (300KB+)
import { formatDistance } from 'date-fns' // 15KB tree-shaken
// Instead of axios (40KB)
// Use native fetch with a tiny wrapperBundle size targets:
- Background: < 100KB
- Content script: < 50KB per script
- Popup/Options: < 200KB
Note: Each WXT entrypoint (background, content scripts, popup) is a self-contained bundle. Unlike web apps, extension entrypoints cannot share code chunks because they run in separate contexts.
Reference: WXT Build CLI
Load WASM and Heavy Assets Dynamically
WebAssembly modules and large assets should be loaded on-demand, not bundled into entry files. This keeps initial load fast for users who don't need heavy features.
Incorrect (WASM bundled with content script):
// content.ts - WASM loaded even on pages that don't need it
import init, { processImage } from 'image-processor-wasm'
export default defineContentScript({
matches: ['*://*/*'],
async main() {
await init()
// Most pages won't use this feature
}
})Correct (WASM loaded on demand):
// content.ts
export default defineContentScript({
matches: ['*://*/*'],
main() {
browser.runtime.onMessage.addListener(async (message, sender, sendResponse) => {
if (message.type === 'PROCESS_IMAGE') {
// Load WASM only when needed
const { init, processImage } = await import('image-processor-wasm')
await init()
const result = await processImage(message.imageData)
sendResponse(result)
}
return true
})
}
})For public assets (load from extension URL):
// wxt.config.ts
export default defineConfig({
manifest: {
web_accessible_resources: [{
resources: ['wasm/*.wasm'],
matches: ['<all_urls>']
}]
}
})
// content.ts
async function loadWasm() {
const wasmUrl = browser.runtime.getURL('wasm/processor.wasm')
const response = await fetch(wasmUrl)
const buffer = await response.arrayBuffer()
const module = await WebAssembly.instantiate(buffer)
return module.instance.exports
}Reference: WXT Public Assets
Minimize Content Script Size
Content scripts run on every matching page. Large content scripts slow down page loads across all affected sites. Keep them minimal.
Incorrect (heavy content script):
// content.ts - includes full UI framework and utilities
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { FullFeaturedUI } from '@/components/FullFeaturedUI'
export default defineContentScript({
matches: ['*://*/*'],
main() {
// 200KB+ content script on every page
const root = ReactDOM.createRoot(container)
root.render(
<QueryClientProvider client={new QueryClient()}>
<FullFeaturedUI />
</QueryClientProvider>
)
}
})Correct (minimal content script, heavy UI in popup/iframe):
// content.ts - lightweight detection and injection only
export default defineContentScript({
matches: ['*://*/*'],
main(ctx) {
// Only inject UI when needed (~5KB)
browser.runtime.onMessage.addListener((message) => {
if (message.type === 'SHOW_UI') {
injectMinimalUI(ctx)
}
})
}
})
function injectMinimalUI(ctx: ContentScriptContext) {
// Use WXT's createShadowRootUi for isolated styling
const ui = createShadowRootUi(ctx, {
name: 'my-extension-ui',
position: 'inline',
onMount: (container) => {
// Minimal vanilla JS UI, or lazy-load framework
container.innerHTML = `<button id="open-popup">Open</button>`
container.querySelector('#open-popup')?.addEventListener('click', () => {
browser.runtime.sendMessage({ type: 'OPEN_POPUP' })
})
}
})
ui.mount()
}Content script size targets:
- Detection/injection only: < 10KB
- With minimal UI: < 30KB
- Maximum recommended: < 50KB
Reference: WXT Content Script UI
Split Code by Entrypoint
Each WXT entrypoint (background, popup, content script) should only import what it needs. Shared code between entrypoints bloats all bundles.
Incorrect (shared heavy imports):
// utils/index.ts - barrel file with everything
export { formatDate, parseDate } from './dates'
export { fetchAPI, processResponse } from './api'
export { renderChart, ChartConfig } from './charts' // Heavy charting library
// popup.ts - only needs dates, but gets charts too
import { formatDate } from '@/utils'
// content.ts - only needs api, but gets charts too
import { fetchAPI } from '@/utils'Correct (entrypoint-specific imports):
// utils/dates.ts
export { formatDate, parseDate }
// utils/api.ts
export { fetchAPI, processResponse }
// utils/charts.ts - only imported where needed
export { renderChart, ChartConfig }
// popup.ts - imports only dates
import { formatDate } from '@/utils/dates'
// content.ts - imports only api
import { fetchAPI } from '@/utils/api'
// options.ts - imports charts when needed
import { renderChart } from '@/utils/charts'Alternative (dynamic imports for heavy deps):
// popup.ts
async function showAnalytics() {
// Only load charts library when user opens analytics
const { renderChart } = await import('@/utils/charts')
renderChart(container, data)
}Reference: WXT Build Configuration
Use Direct Imports for Icon Libraries
Icon libraries like Lucide, Heroicons, and FontAwesome contain thousands of icons. Import individual icons directly instead of from barrel files.
Incorrect (imports entire icon library):
// Imports all 1,500+ icons (2MB+)
import { Check, X, Menu } from 'lucide-react'Correct (direct icon imports):
// Imports only 3 icons (~6KB)
import Check from 'lucide-react/dist/esm/icons/check'
import X from 'lucide-react/dist/esm/icons/x'
import Menu from 'lucide-react/dist/esm/icons/menu'Alternative (unplugin-icons for automatic tree-shaking):
// wxt.config.ts
import Icons from 'unplugin-icons/vite'
export default defineConfig({
vite: () => ({
plugins: [
Icons({
compiler: 'jsx', // or 'vue3', 'svelte'
autoInstall: true
})
]
})
})
// Usage - automatic tree-shaking
import IconCheck from '~icons/lucide/check'
import IconMenu from '~icons/lucide/menu'Icon library sizes (full vs tree-shaken):
| Library | Full | Per Icon |
|---|---|---|
| Lucide | 2MB+ | ~2KB |
| Heroicons | 1.5MB | ~1KB |
| FontAwesome | 3MB+ | ~3KB |
Reference: unplugin-icons
Configure allFrames for Iframe Handling
By default, content scripts only run in the top frame. Set allFrames: true when you need to process content in iframes, but be aware of the performance cost.
Incorrect (missing iframe content):
export default defineContentScript({
matches: ['*://*.example.com/*'],
// Default: allFrames: false
main() {
// Only finds elements in top frame
const videos = document.querySelectorAll('video')
videos.forEach(addControls)
// Misses videos embedded in iframes
}
})Correct (explicit frame handling):
// When you need to process iframes
export default defineContentScript({
matches: ['*://*.example.com/*'],
allFrames: true,
main() {
const videos = document.querySelectorAll('video')
videos.forEach(addControls)
// Now runs in every frame
}
})
// When you only want top frame (explicit for clarity)
export default defineContentScript({
matches: ['*://*.example.com/*'],
allFrames: false,
main() {
// Communicate with iframes via postMessage if needed
window.addEventListener('message', (event) => {
if (event.data.type === 'VIDEO_FOUND') {
handleVideoInIframe(event.source, event.data)
}
})
}
})Performance note: With allFrames: true, your content script executes once per frame. For pages with many iframes (ads, embeds), this multiplies resource usage.
Reference: Chrome Content Scripts - Frames
Choose the Correct Execution World
Content scripts can run in ISOLATED (default) or MAIN world. ISOLATED has extension API access but cannot see page variables. MAIN can see page variables but loses extension API access.
Incorrect (wrong world for use case):
// Trying to access page variables from ISOLATED world
export default defineContentScript({
matches: ['*://*.example.com/*'],
main() {
// undefined - page variables not visible in ISOLATED world
const pageData = window.appState
browser.runtime.sendMessage({ data: pageData })
}
})Correct (use MAIN world for page access):
// entrypoints/page-reader.content.ts - ISOLATED world parent
export default defineContentScript({
matches: ['*://*.example.com/*'],
async main() {
// Inject into MAIN world to access page variables
await injectScript('/page-reader-main.js', { keepInDom: true })
// Receive data via custom events
window.addEventListener('PAGE_DATA', ((event: CustomEvent) => {
browser.runtime.sendMessage({ data: event.detail })
}) as EventListener)
}
})
// entrypoints/page-reader-main.ts - MAIN world script
export default defineUnlistedScript(() => {
// Can access page variables
const pageData = (window as any).appState
window.dispatchEvent(new CustomEvent('PAGE_DATA', { detail: pageData }))
})When to use each world:
- ISOLATED: DOM manipulation, extension API access, secure operations
- MAIN: Reading page JavaScript variables, intercepting XHR/fetch, modifying prototypes
Reference: WXT Content Scripts - Isolated vs Main World
Use Runtime Registration for Conditional Injection
Use runtime registration instead of manifest registration when content scripts should only inject after user grants optional permissions or based on user settings.
Incorrect (always injected via manifest):
// Content script always runs on all sites
export default defineContentScript({
matches: ['*://*/*'],
main() {
// Runs everywhere, even before user configures sites
enhancePage()
}
})Correct (runtime registration based on permissions):
// entrypoints/enhancer.content.ts
export default defineContentScript({
matches: ['*://*/*'],
registration: 'runtime', // Not in manifest, registered dynamically
main() {
enhancePage()
}
})
// entrypoints/background.ts
export default defineBackground(() => {
browser.permissions.onAdded.addListener(async (permissions) => {
if (permissions.origins?.length) {
// Register content script for newly permitted origins
await browser.scripting.registerContentScripts([{
id: 'enhancer',
matches: permissions.origins,
js: ['/content-scripts/enhancer.js']
}])
}
})
browser.permissions.onRemoved.addListener(async (permissions) => {
if (permissions.origins?.length) {
// Unregister when permissions revoked
await browser.scripting.unregisterContentScripts({
ids: ['enhancer']
})
}
})
})When to use runtime registration:
- Optional host permissions (user must grant per-site access)
- User-configurable site lists
- Enterprise deployments with site restrictions
Reference: Chrome Scripting API
Select Appropriate runAt Timing
The runAt option controls when content scripts execute. Wrong timing causes race conditions where scripts run before elements exist or after events have fired.
Incorrect (default timing too late to intercept page scripts):
export default defineContentScript({
matches: ['*://*.example.com/*'],
// Default runAt: 'document_idle' - runs after page scripts
main() {
// Too late - page's tracking script already initialized
Object.defineProperty(window, 'trackingEnabled', {
value: false,
writable: false
})
}
})Correct (explicit timing for use case):
// For intercepting requests or modifying page before load
export default defineContentScript({
matches: ['*://*.example.com/*'],
runAt: 'document_start',
main() {
// Runs before any page scripts
Object.defineProperty(window, 'trackingEnabled', {
value: false,
writable: false
})
}
})
// For DOM manipulation after document ready
export default defineContentScript({
matches: ['*://*.example.com/*'],
runAt: 'document_end',
main() {
// DOM is fully parsed but resources may still be loading
const container = document.querySelector('.main-content')
injectUI(container)
}
})
// For non-critical enhancements (default, best performance)
export default defineContentScript({
matches: ['*://*.example.com/*'],
runAt: 'document_idle',
main() {
// Runs when browser is idle, least impact on page load
addEnhancementFeatures()
}
})Timing reference:
document_start: Before any DOM exists, before page scriptsdocument_end: After DOM parsed, before images/iframes loadeddocument_idle: After page fully loaded or browser idle
Reference: Chrome Content Scripts runAt
Handle SPA Navigation with wxt:locationchange
Content scripts don't re-execute on SPA (Single Page Application) navigation because the page doesn't fully reload. Use WXT's wxt:locationchange event to detect URL changes and re-run logic.
Incorrect (only runs on initial page load):
export default defineContentScript({
matches: ['*://*.youtube.com/watch*'],
main(ctx) {
// Only runs once on initial page load
// Breaks when user navigates between videos via SPA
const videoId = new URL(location.href).searchParams.get('v')
injectOverlay(videoId)
}
})Correct (react to SPA navigation):
export default defineContentScript({
matches: ['*://*.youtube.com/*'],
main(ctx) {
const watchPattern = new MatchPattern('*://*.youtube.com/watch*')
// Run on initial load if URL matches
if (watchPattern.includes(location.href)) {
mountVideoOverlay(ctx)
}
// React to SPA navigation
ctx.addEventListener(window, 'wxt:locationchange', ({ newUrl }) => {
if (watchPattern.includes(newUrl)) {
mountVideoOverlay(ctx)
}
})
}
})
function mountVideoOverlay(ctx: ContentScriptContext) {
const videoId = new URL(location.href).searchParams.get('v')
if (!videoId) return
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#movie_player',
onMount: (container) => {
container.textContent = `Overlay for ${videoId}`
}
})
ui.mount()
}For dynamic elements, use autoMount():
export default defineContentScript({
matches: ['*://*/*'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: '#dynamic-target',
onMount: (container) => {
container.textContent = 'Mounted!'
}
})
// Automatically mount/unmount as anchor appears/disappears
ui.autoMount()
}
})Key points:
- Use broad
matchespattern, then filter withMatchPatterninside the handler wxt:locationchangefires on both pushState and replaceState navigationautoMount()uses MutationObserver internally to handle dynamic DOM elements- Use
ctx.addEventListenerfor auto-cleanup on context invalidation
Reference: WXT Content Scripts - SPAs
Handle Context Invalidation
When an extension updates, existing content scripts become "orphaned" - they still run but can't communicate with the new background script. Use WXT's context to detect invalidation and clean up.
Incorrect (no invalidation handling):
export default defineContentScript({
matches: ['*://*.example.com/*'],
main() {
setInterval(async () => {
// Throws error after extension update
const response = await browser.runtime.sendMessage({ type: 'PING' })
updateUI(response)
}, 5000)
}
})Correct (handle context invalidation):
export default defineContentScript({
matches: ['*://*.example.com/*'],
main(ctx) {
const intervalId = setInterval(async () => {
if (ctx.isInvalidated) {
clearInterval(intervalId)
showReloadPrompt()
return
}
try {
const response = await browser.runtime.sendMessage({ type: 'PING' })
updateUI(response)
} catch (error) {
if (isExtensionContextInvalidated(error)) {
clearInterval(intervalId)
showReloadPrompt()
}
}
}, 5000)
// Clean up when context becomes invalid
ctx.onInvalidated(() => {
clearInterval(intervalId)
showReloadPrompt()
})
}
})
function isExtensionContextInvalidated(error: unknown): boolean {
return error instanceof Error &&
error.message.includes('Extension context invalidated')
}
function showReloadPrompt() {
const banner = document.createElement('div')
banner.textContent = 'Extension updated. Please refresh the page.'
document.body.prepend(banner)
}Reference: WXT Content Script Context
Place All Runtime Code Inside main()
WXT imports entrypoint files in a Node.js build environment where browser APIs don't exist. All code using browser.* APIs must be inside the main() function.
Incorrect (API usage at module level):
// entrypoints/content.ts
browser.runtime.onMessage.addListener((message) => {
// Error: browser is not defined during build
handleMessage(message)
})
export default defineContentScript({
matches: ['*://*.example.com/*'],
main() {
console.log('Content script loaded')
}
})Correct (API usage inside main):
// entrypoints/content.ts
export default defineContentScript({
matches: ['*://*.example.com/*'],
main() {
browser.runtime.onMessage.addListener((message) => {
handleMessage(message)
})
console.log('Content script loaded')
}
})Note: Pure utility functions without browser API usage can remain at module level for sharing between files.
Reference: WXT Extension APIs
Configure Content Security Policy Correctly
MV3 enforces strict CSP for extension pages. Configure CSP to allow necessary functionality while maintaining security.
Incorrect (trying to bypass CSP):
// wxt.config.ts
export default defineConfig({
manifest: {
content_security_policy: {
extension_pages: "script-src 'self' 'unsafe-inline' 'unsafe-eval';"
// Error: MV3 blocks unsafe-inline and unsafe-eval
}
}
})Correct (MV3-compliant CSP):
// wxt.config.ts
export default defineConfig({
manifest: {
content_security_policy: {
extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}
}
})For WASM support:
// wxt.config.ts
export default defineConfig({
manifest: {
content_security_policy: {
// Allow WASM compilation
extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}
}
})For sandbox pages (less restricted):
// wxt.config.ts
export default defineConfig({
manifest: {
sandbox: {
pages: ['sandbox.html']
},
content_security_policy: {
// Sandbox pages can have eval for templating engines
sandbox: "sandbox allow-scripts; script-src 'self' 'unsafe-eval'"
}
}
})Note: Sandbox pages cannot use extension APIs but can run less-restricted JavaScript. Use postMessage to communicate between sandbox and extension pages.
Reference: Chrome Extension CSP
Configure Cross-Browser Compatibility
WXT can build for multiple browsers from single codebase. Use browser-specific configuration and conditional code for compatibility.
Incorrect (Chrome-only assumptions):
// wxt.config.ts
export default defineConfig({
manifest: {
// MV3-only features that Firefox doesn't support
side_panel: {
default_path: 'sidepanel.html'
}
}
})
// background.ts
export default defineBackground(() => {
// Chrome-only API
chrome.sidePanel.open({ windowId: 1 })
})Correct (cross-browser configuration):
// wxt.config.ts
export default defineConfig({
manifest: ({ browser, manifestVersion }) => ({
// Shared configuration
permissions: ['storage'],
// Browser-specific sidepanel (Chrome/Edge only)
...(browser === 'chrome' && {
side_panel: { default_path: 'sidepanel.html' }
}),
// Firefox uses sidebar_action instead
...(browser === 'firefox' && {
sidebar_action: {
default_panel: 'sidebar.html',
default_title: 'My Extension'
}
})
})
})
// background.ts
export default defineBackground(() => {
// Check API availability before use
if ('sidePanel' in browser) {
browser.sidePanel.open({ windowId: 1 })
} else if ('sidebarAction' in browser) {
browser.sidebarAction.open()
}
})Browser targeting in entrypoints:
// entrypoints/chrome-only.content.ts
export default defineContentScript({
matches: ['*://*/*'],
include: ['chrome'], // Only included in Chrome builds
main() {
// Chrome-specific functionality
}
})
// entrypoints/firefox-only.content.ts
export default defineContentScript({
matches: ['*://*/*'],
include: ['firefox'], // Only included in Firefox builds
main() {
// Firefox-specific functionality
}
})Reference: WXT Browser Targets
Request Minimal Required Permissions
Request only the permissions your extension actually needs. Excessive permissions trigger Chrome Web Store review delays and reduce user trust.
Incorrect (over-permissioned):
// wxt.config.ts
export default defineConfig({
manifest: {
permissions: [
'tabs',
'storage',
'activeTab',
'scripting',
'webRequest',
'webNavigation',
'cookies',
'history',
'bookmarks', // Never used
'downloads', // Never used
'<all_urls>' // Too broad
]
}
})Correct (minimal permissions):
// wxt.config.ts
export default defineConfig({
manifest: {
permissions: [
'storage', // For user preferences
'activeTab' // For current tab access on user action
],
optional_permissions: [
'tabs', // Request when user needs tab listing feature
'bookmarks' // Request when user enables bookmark feature
],
host_permissions: [
'https://api.example.com/*' // Only your API domain
],
optional_host_permissions: [
'https://*/*' // Request broad access only when needed
]
}
})Permission alternatives:
| Instead of | Use | Why |
|---|---|---|
<all_urls> | activeTab | Only accesses current tab on user action |
tabs | activeTab | Unless you need to list/query tabs |
host_permissions: [*] | Specific domains | Limits data access scope |
Reference: Chrome Permissions Best Practices
Use Optional Permissions for Progressive Access
Use optional permissions to reduce install-time warnings. Request additional permissions only when the user needs the associated feature.
Incorrect (all permissions at install):
// wxt.config.ts
export default defineConfig({
manifest: {
permissions: ['tabs', 'history', 'bookmarks', 'downloads'],
host_permissions: ['<all_urls>']
}
})
// User sees scary warnings at install, may abandonCorrect (core permissions only, optional for features):
// wxt.config.ts
export default defineConfig({
manifest: {
permissions: ['storage'],
optional_permissions: ['tabs', 'history', 'bookmarks', 'downloads'],
optional_host_permissions: ['<all_urls>']
}
})
// options.ts - request when user enables feature
async function enableBookmarkSync() {
const granted = await browser.permissions.request({
permissions: ['bookmarks']
})
if (granted) {
await settings.setValue({ ...currentSettings, bookmarkSync: true })
startBookmarkSync()
} else {
showPermissionDeniedMessage()
}
}
// Check permission before using feature
async function getBookmarks() {
const hasPermission = await browser.permissions.contains({
permissions: ['bookmarks']
})
if (!hasPermission) {
showEnableBookmarkSyncPrompt()
return []
}
return browser.bookmarks.getTree()
}Reference: Chrome Optional Permissions
Scope Web Accessible Resources Narrowly
Web accessible resources can be accessed by any web page. Limit them to specific domains to prevent extension fingerprinting and unauthorized resource access.
Incorrect (resources accessible everywhere):
// wxt.config.ts
export default defineConfig({
manifest: {
web_accessible_resources: [{
resources: ['images/*', 'scripts/*', 'styles/*'],
matches: ['<all_urls>'] // Any site can access these
}]
}
})
// Sites can detect your extension by probing for these resourcesCorrect (scoped to necessary domains):
// wxt.config.ts
export default defineConfig({
manifest: {
web_accessible_resources: [
{
// Main world scripts - needed on specific sites
resources: ['injected-scripts/*.js'],
matches: ['https://*.example.com/*']
},
{
// Extension ID used for cross-origin requests
resources: ['fonts/*.woff2'],
matches: ['https://*.myservice.com/*'],
use_dynamic_url: true // Randomize URL to prevent fingerprinting
}
]
}
})Alternative (inject content via content script instead):
// Instead of web accessible CSS
export default defineContentScript({
matches: ['*://*.example.com/*'],
css: ['styles/injected.css'], // Injected directly, not web accessible
main() {
// Content script code
}
})Note: Use use_dynamic_url: true in MV3 to generate randomized URLs that change per session, making fingerprinting harder.
Reference: Chrome Web Accessible Resources
Avoid Circular Message Loops
Message handlers that send messages can create infinite loops. Use message type checks and sender validation to prevent circular message patterns.
Incorrect (infinite message loop):
// background.ts
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'PROCESS_DATA') {
const result = transformUserData(message.data)
// Sends message that triggers same listener
browser.runtime.sendMessage({ type: 'PROCESS_DATA', data: result })
return true
}
})Correct (use distinct message types):
// background.ts
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
switch (message.type) {
case 'PROCESS_DATA':
transformUserData(message.data).then((result) => {
// Different message type for result
browser.runtime.sendMessage({ type: 'DATA_PROCESSED', result })
})
return true
case 'DATA_PROCESSED':
// Handle processed data - no further messages
updateUI(message.result)
return false
}
})Alternative (track message origin):
interface TrackedMessage {
type: string
data: unknown
_origin?: 'background' | 'content' | 'popup'
}
browser.runtime.onMessage.addListener((message: TrackedMessage, sender, sendResponse) => {
// Ignore messages we sent
if (message._origin === 'background') {
return false
}
if (message.type === 'SYNC_STATE') {
const newState = mergeState(message.data)
// Mark origin to prevent loop
browser.runtime.sendMessage({
type: 'SYNC_STATE',
data: newState,
_origin: 'background'
})
}
})Note: `@webext-core/messaging` with named message types reduces the risk of circular loops through explicit sender/receiver separation.
Reference: Chrome Messaging Debugging
Handle Missing Message Receivers
sendMessage throws an error if no listener is registered. Always handle this case, especially when content scripts may not be injected on all pages.
Incorrect (unhandled rejection):
// popup.ts
async function sendToContentScript() {
// Throws if content script not injected
const response = await browser.tabs.sendMessage(tabId, { type: 'GET_DATA' })
displayData(response)
}Correct (handle no receiver case):
// popup.ts
async function sendToContentScript(tabId: number) {
try {
const response = await browser.tabs.sendMessage(tabId, { type: 'GET_DATA' })
displayData(response)
} catch (error) {
if (isNoReceiverError(error)) {
// Content script not injected - inject it first
await browser.scripting.executeScript({
target: { tabId },
files: ['/content-scripts/content.js']
})
// Retry after injection
const response = await browser.tabs.sendMessage(tabId, { type: 'GET_DATA' })
displayData(response)
} else {
throw error
}
}
}
function isNoReceiverError(error: unknown): boolean {
return error instanceof Error && (
error.message.includes('Receiving end does not exist') ||
error.message.includes('Could not establish connection')
)
}Alternative (check before sending):
async function sendIfContentScriptReady(tabId: number, message: Message) {
const tab = await browser.tabs.get(tabId)
// Check if URL matches content script patterns
if (!tab.url?.startsWith('http')) {
console.log('Content script cannot run on this page')
return null
}
try {
return await browser.tabs.sendMessage(tabId, message)
} catch {
return null
}
}Note: `@webext-core/messaging` handles receiver errors automatically, reducing the need for manual error handling.
Reference: Chrome Messaging Best Practices
Return true for Async Message Handlers
When handling messages asynchronously with the raw browser.runtime.onMessage API, you must return true synchronously to keep the message channel open. Without this, sendResponse is called before your async work completes.
Important (WXT v0.20+): WXT removed webextension-polyfill, so returning a Promise from addListener no longer works. You must use sendResponse + return true. Consider using `@webext-core/messaging` which handles this automatically.
Incorrect (channel closes before async completes):
export default defineBackground(() => {
browser.runtime.onMessage.addListener(async (message) => {
if (message.type === 'FETCH_DATA') {
const data = await fetchData(message.url)
return data // Never reaches sender - channel already closed
}
})
})Correct (keep channel open with return true):
export default defineBackground(() => {
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'FETCH_DATA') {
fetchData(message.url).then((data) => {
sendResponse(data)
})
return true // Keep message channel open
}
})
})Alternative (wrap in promise handler):
export default defineBackground(() => {
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'FETCH_DATA') {
handleFetchMessage(message)
.then(sendResponse)
.catch((error) => sendResponse({ error: error.message }))
return true
}
})
})
async function handleFetchMessage(message: FetchMessage): Promise<FetchResponse> {
const data = await fetchData(message.url)
return { success: true, data }
}Reference: Chrome Runtime Messaging
Define Type-Safe Message Protocols
Use @webext-core/messaging for type-safe messaging between extension contexts. WXT's messaging guide recommends this library over raw browser.runtime.onMessage because it handles return true, type safety, and error handling automatically.
Incorrect (untyped raw messages):
// background.ts
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_USER') {
// No type safety - easy to typo 'user' vs 'userId'
getUser(message.userId).then(sendResponse)
return true
}
})
// content.ts
// Typo: 'user' instead of 'userId' - fails silently
browser.runtime.sendMessage({ type: 'GET_USER', user: 123 })Correct (@webext-core/messaging with ProtocolMap):
// utils/messaging.ts
import { defineExtensionMessaging } from '@webext-core/messaging'
interface ProtocolMap {
getUser(data: { userId: number }): User | null
updateSettings(data: { settings: Settings }): { success: boolean }
ping(): 'pong'
}
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>()// background.ts
export default defineBackground(() => {
onMessage('getUser', ({ data }) => {
return getUser(data.userId) // TypeScript knows userId exists
})
onMessage('updateSettings', ({ data }) => {
return updateSettings(data.settings) // Return type checked
})
onMessage('ping', () => 'pong')
})// content.ts or popup.ts
const user = await sendMessage('getUser', { userId: 123 })
// TypeScript error if you typo: sendMessage('getUser', { user: 123 })
// TypeScript knows user is User | nullAlternative (raw API when you cannot add dependencies):
// types/messages.ts
export type Message =
| { type: 'GET_USER'; userId: number }
| { type: 'UPDATE_SETTINGS'; settings: Settings }
// background.ts - must manually return true for async
browser.runtime.onMessage.addListener((message: Message, sender, sendResponse) => {
switch (message.type) {
case 'GET_USER':
getUser(message.userId).then(sendResponse)
return true
}
})Install: npm install @webext-core/messaging
Reference: WXT Messaging Guide
Use Ports for Streaming Communication
For long-lived connections or streaming data, use runtime.connect ports instead of repeated sendMessage calls. Ports maintain an open channel without reconnection overhead.
Incorrect (repeated sendMessage for streaming):
// content.ts - polls for updates
export default defineContentScript({
matches: ['*://*.example.com/*'],
main() {
setInterval(async () => {
// Creates new connection every 100ms
const update = await browser.runtime.sendMessage({ type: 'GET_UPDATE' })
applyUpdate(update)
}, 100)
}
})Correct (port for streaming updates):
// content.ts - maintains persistent connection
export default defineContentScript({
matches: ['*://*.example.com/*'],
main(ctx) {
const port = browser.runtime.connect({ name: 'updates' })
port.onMessage.addListener((update) => {
applyUpdate(update)
})
port.onDisconnect.addListener(() => {
if (!ctx.isInvalidated) {
// Reconnect if not due to extension update
setTimeout(() => reconnect(), 1000)
}
})
// Request to start streaming
port.postMessage({ type: 'START_UPDATES' })
}
})
// background.ts
export default defineBackground(() => {
browser.runtime.onConnect.addListener((port) => {
if (port.name === 'updates') {
let intervalId: ReturnType<typeof setInterval>
port.onMessage.addListener((message) => {
if (message.type === 'START_UPDATES') {
intervalId = setInterval(() => {
const update = generateUpdate()
port.postMessage(update)
}, 100)
}
})
port.onDisconnect.addListener(() => {
clearInterval(intervalId)
})
}
})
})When to use ports:
- Streaming data (live updates, progress)
- Bidirectional communication
- Keep-alive connections for long operations
Reference: Chrome Long-Lived Connections
Use tabs.sendMessage for Content Scripts
Use browser.tabs.sendMessage to send messages from background to specific content scripts. Using runtime.sendMessage from background won't reach content scripts.
Incorrect (runtime.sendMessage from background):
// background.ts
export default defineBackground(() => {
browser.action.onClicked.addListener(async (tab) => {
// This goes to popup/options, NOT content scripts
await browser.runtime.sendMessage({ type: 'TOGGLE_FEATURE' })
})
})Correct (tabs.sendMessage targets content scripts):
// background.ts
export default defineBackground(() => {
browser.action.onClicked.addListener(async (tab) => {
if (!tab.id) return
try {
await browser.tabs.sendMessage(tab.id, { type: 'TOGGLE_FEATURE' })
} catch (error) {
// Content script not injected on this page
console.log('Content script not available on this tab')
}
})
})
// content.ts
export default defineContentScript({
matches: ['*://*/*'],
main() {
browser.runtime.onMessage.addListener((message) => {
if (message.type === 'TOGGLE_FEATURE') {
toggleFeature()
}
})
}
})Note: tabs.sendMessage throws if no content script is listening. Always wrap in try-catch or check if the tab URL matches your content script patterns first.
Note: `@webext-core/messaging` provides sendMessage with built-in tab targeting via the tabId option, handling this pattern automatically.
Reference: Chrome Tabs sendMessage
Batch Storage Operations
Multiple sequential storage operations trigger multiple disk writes. Group related data into a single defineItem to reduce I/O overhead.
Incorrect (multiple sequential operations):
async function saveFormData(form: FormData) {
await storage.setItem('local:name', form.name)
await storage.setItem('local:email', form.email)
await storage.setItem('local:phone', form.phone)
await storage.setItem('local:address', form.address)
// 4 separate disk writes
}
async function loadFormData(): Promise<FormData> {
const name = await storage.getItem('local:name')
const email = await storage.getItem('local:email')
const phone = await storage.getItem('local:phone')
const address = await storage.getItem('local:address')
// 4 separate disk reads
return { name, email, phone, address }
}Correct (single defineItem for related data):
interface FormData {
name: string
email: string
phone: string
address: string
}
const formData = storage.defineItem<FormData>('local:formData', {
fallback: { name: '', email: '', phone: '', address: '' }
})
async function saveFormData(form: FormData) {
await formData.setValue(form) // 1 disk write
}
async function loadFormData(): Promise<FormData> {
return await formData.getValue() // 1 disk read, never null
}
// Partial updates
async function updateEmail(email: string) {
const current = await formData.getValue()
await formData.setValue({ ...current, email })
}When to use separate items instead:
// Separate items when data has different lifecycles or sync needs
const userPrefs = storage.defineItem<UserPrefs>('sync:preferences', {
fallback: { theme: 'light', language: 'en' }
})
const pageCache = storage.defineItem<PageCache>('local:cache', {
fallback: {}
})
// Preferences sync across devices, cache is local-onlyKey principle: Group data that is read/written together into a single storage item. Split data with different storage areas, sync needs, or independent lifecycles.
Reference: WXT Storage API
Choose the Correct Storage Area
WXT supports three storage areas with different characteristics. Choose based on persistence needs, sync requirements, and data size.
Incorrect (wrong area for use case):
// Using sync for large data - exceeds quota
const pageCache = storage.defineItem<Record<string, string>>('sync:pageCache', {
fallback: {}
})
// sync has 8KB per-item limit, 100KB total
// Using local for session-only data - wastes disk
const temporaryState = storage.defineItem<TemporaryState>('local:temp', {
fallback: {}
})
// Persists across browser restarts unnecessarilyCorrect (appropriate area for each use case):
// Session: ephemeral data, cleared on browser restart
// Good for: active tab state, temporary flags, in-progress work
const activeTabData = storage.defineItem<TabData>('session:activeTab', {
fallback: null
})
// Local: persistent data, not synced
// Good for: caches, large data, device-specific settings
const pageCache = storage.defineItem<Record<string, CachedPage>>('local:cache', {
fallback: {}
})
// Sync: persistent data, synced across devices
// Good for: user preferences, small settings (8KB limit per item)
const userPreferences = storage.defineItem<UserPreferences>('sync:preferences', {
fallback: { theme: 'light', language: 'en' }
})Storage area reference:
| Area | Persistence | Synced | Quota |
|---|---|---|---|
session: | Browser session | No | 10MB |
local: | Permanent | No | 10MB |
sync: | Permanent | Yes | 100KB total, 8KB/item |
Reference: Chrome Storage Areas
Handle Storage Quota Errors
Storage areas have quota limits. Always handle QUOTA_BYTES_PER_ITEM and QUOTA_BYTES errors to prevent data loss and provide user feedback.
Incorrect (unhandled quota errors):
async function cachePageContent(url: string, content: string) {
const cache = await pageCache.getValue()
cache[url] = content
// Throws if cache exceeds quota - data lost
await pageCache.setValue(cache)
}Correct (quota-aware caching):
const MAX_CACHE_ENTRIES = 100
const MAX_CONTENT_SIZE = 50_000 // 50KB per entry
async function cachePageContent(url: string, content: string) {
// Validate before storing
if (content.length > MAX_CONTENT_SIZE) {
content = content.slice(0, MAX_CONTENT_SIZE)
console.warn('Content truncated to fit quota')
}
try {
const cache = await pageCache.getValue()
// Evict old entries if at capacity
const entries = Object.entries(cache)
if (entries.length >= MAX_CACHE_ENTRIES) {
const oldest = entries
.sort(([, a], [, b]) => a.timestamp - b.timestamp)
.slice(0, entries.length - MAX_CACHE_ENTRIES + 1)
oldest.forEach(([key]) => delete cache[key])
}
cache[url] = { content, timestamp: Date.now() }
await pageCache.setValue(cache)
} catch (error) {
if (isQuotaError(error)) {
// Clear cache and retry
await pageCache.setValue({})
await pageCache.setValue({ [url]: { content, timestamp: Date.now() } })
} else {
throw error
}
}
}
function isQuotaError(error: unknown): boolean {
return error instanceof Error && (
error.message.includes('QUOTA_BYTES') ||
error.message.includes('quota')
)
}Reference: Chrome Storage Quotas
Use storage.defineItem for Type-Safe Access
Use storage.defineItem to define typed storage items with fallback values. This prevents null checks scattered throughout your code and catches type errors at build time.
Incorrect (raw getItem with manual typing):
// Scattered null checks, no type safety
async function getSettings() {
const settings = await storage.getItem<Settings>('local:settings')
if (!settings) {
return { theme: 'light', notifications: true }
}
return settings
}
async function updateTheme(theme: string) {
const settings = await storage.getItem<Settings>('local:settings')
await storage.setItem('local:settings', { ...settings, theme })
}Correct (defineItem with fallback):
// utils/storage.ts
interface Settings {
theme: 'light' | 'dark'
notifications: boolean
volume: number
}
export const settings = storage.defineItem<Settings>('local:settings', {
fallback: { theme: 'light', notifications: true, volume: 80 }
})
// Usage - no null checks needed
async function getSettings() {
return await settings.getValue() // Always returns Settings, never null
}
async function updateTheme(theme: 'light' | 'dark') {
const current = await settings.getValue()
await settings.setValue({ ...current, theme })
}
// Watch for changes
settings.watch((newSettings) => {
applySettings(newSettings) // TypeScript knows this is Settings
})When NOT to use this pattern:
- Dynamic keys that cannot be known at compile time
- One-time migration scripts that access legacy storage formats
- Reading storage items created by other extensions
`fallback` vs `init`:
fallback— in-memory default returned when storage is empty. Value is NOT persisted.init— one-time initializer that saves the value to storage on first access. Use for unique IDs, install dates, etc.
// fallback: returns 'dark' but doesn't save to storage
const theme = storage.defineItem('local:theme', { fallback: 'dark' })
// init: generates UUID and saves to storage on first access
const installId = storage.defineItem('local:installId', {
init: () => crypto.randomUUID()
})Omit both when null is a valid state. See `store-versioned-migrations` for evolving storage schemas over time.
Reference: WXT Storage defineItem
Use Storage Versioning for Schema Migrations
Production extensions evolve over time. Use version and migrations in storage.defineItem to safely transform stored data when the schema changes.
Incorrect (manual migration scattered across codebase):
async function getIgnoredSites() {
const sites = await storage.getItem<string[] | IgnoredSite[]>('local:ignoredSites')
if (!sites) return []
// Check if migration needed by inspecting shape
if (typeof sites[0] === 'string') {
// Old format - migrate in place
const migrated = (sites as string[]).map((url) => ({ id: crypto.randomUUID(), url }))
await storage.setItem('local:ignoredSites', migrated)
return migrated
}
return sites as IgnoredSite[]
}Correct (declarative versioning with migrations):
interface IgnoredSiteV2 {
id: string
url: string
}
// V1 was string[] (unversioned)
const ignoredSites = storage.defineItem<IgnoredSiteV2[]>('local:ignoredSites', {
fallback: [],
version: 2,
migrations: {
// Runs automatically when migrating from v1 (or unversioned) to v2
2: (oldSites: string[]): IgnoredSiteV2[] => {
return oldSites.map((url) => ({
id: crypto.randomUUID(),
url
}))
}
}
})
// Usage - migrations run transparently on first access
const sites = await ignoredSites.getValue() // Always IgnoredSiteV2[]Multi-version migration chain:
interface IgnoredSiteV3 {
id: string
url: string
addedAt: number
reason?: string
}
const ignoredSites = storage.defineItem<IgnoredSiteV3[]>('local:ignoredSites', {
fallback: [],
version: 3,
migrations: {
2: (v1: string[]): IgnoredSiteV2[] =>
v1.map((url) => ({ id: crypto.randomUUID(), url })),
3: (v2: IgnoredSiteV2[]): IgnoredSiteV3[] =>
v2.map((site) => ({ ...site, addedAt: Date.now() }))
}
})Key concepts:
- Migrations run automatically on first
getValue()call - Migrations execute sequentially: v1 -> v2 -> v3
- Metadata is stored at
key + "$"with the current version - Use
fallbackfor default when no value exists; useinitto persist a default on first access
Reference: WXT Storage Versioning
Use watch() for Reactive Storage Updates
Use watch() to react to storage changes instead of polling or manual refresh. This ensures all contexts (popup, content scripts, options) stay synchronized.
Incorrect (polling for changes):
// popup.ts - polls for settings changes
let currentSettings: Settings
async function init() {
currentSettings = await settings.getValue()
renderUI(currentSettings)
// Wasteful polling every second
setInterval(async () => {
const newSettings = await settings.getValue()
if (JSON.stringify(newSettings) !== JSON.stringify(currentSettings)) {
currentSettings = newSettings
renderUI(currentSettings)
}
}, 1000)
}Correct (reactive watch):
// popup.ts - reacts to changes
async function init() {
const currentSettings = await settings.getValue()
renderUI(currentSettings)
// Automatically updates when storage changes
settings.watch((newSettings) => {
renderUI(newSettings)
})
}With cleanup for content scripts:
export default defineContentScript({
matches: ['*://*/*'],
main(ctx) {
const unwatch = settings.watch((newSettings) => {
applySettingsToPage(newSettings)
})
// Clean up when content script invalidated
ctx.onInvalidated(() => {
unwatch()
})
}
})Note: watch() uses browser.storage.onChanged internally, which fires across all extension contexts automatically.
Reference: WXT Storage Watch
Avoid In-Memory Global State
Service workers can terminate after 30 seconds of inactivity. Any in-memory state is lost when this happens. Use persistent storage for all state that must survive restarts.
Incorrect (state lost on termination):
let tabCounts: Record<number, number> = {}
export default defineBackground(() => {
browser.tabs.onUpdated.addListener((tabId) => {
tabCounts[tabId] = (tabCounts[tabId] || 0) + 1
// State lost when service worker terminates
})
})Correct (state persisted to storage):
const tabCounts = storage.defineItem<Record<number, number>>(
'session:tabCounts',
{ fallback: {} }
)
export default defineBackground(() => {
browser.tabs.onUpdated.addListener(async (tabId) => {
const counts = await tabCounts.getValue()
await tabCounts.setValue({
...counts,
[tabId]: (counts[tabId] || 0) + 1
})
})
})Note: Use session: storage area for ephemeral state that should clear on browser restart, local: for persistent state.
Reference: WXT Storage
Use Declarative Net Request for Blocking
Declarative Net Request rules execute in the browser's network stack, not in JavaScript. They're faster than webRequest and work even when the service worker is inactive.
Incorrect (webRequest blocking, deprecated in MV3):
export default defineBackground(() => {
browser.webRequest.onBeforeRequest.addListener(
(details) => {
if (details.url.includes('tracking.js')) {
return { cancel: true }
}
},
{ urls: ['<all_urls>'] },
['blocking'] // Not available in MV3
)
})Correct (declarative net request rules):
// wxt.config.ts
export default defineConfig({
manifest: {
permissions: ['declarativeNetRequest'],
declarative_net_request: {
rule_resources: [{
id: 'blocking_rules',
enabled: true,
path: 'rules/blocking.json'
}]
}
}
})// public/rules/blocking.json
[
{
"id": 1,
"priority": 1,
"action": { "type": "block" },
"condition": {
"urlFilter": "tracking.js",
"resourceTypes": ["script"]
}
}
]Alternative (dynamic rules from background):
export default defineBackground(() => {
browser.runtime.onInstalled.addListener(async () => {
await browser.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [1],
addRules: [{
id: 1,
priority: 1,
action: { type: 'block' },
condition: {
urlFilter: 'tracking.js',
resourceTypes: ['script']
}
}]
})
})
})Reference: Chrome Declarative Net Request
Handle Install and Update Events
The runtime.onInstalled event fires on first install, extension update, and browser update. Use it to initialize storage, run migrations, and set up default state.
Incorrect (no install/update handling):
export default defineBackground(() => {
// Assumes storage is already initialized
browser.action.onClicked.addListener(async () => {
const settings = await storage.getItem('local:settings')
// Crashes on first install when settings is null
applyTheme(settings.theme)
})
})Correct (proper lifecycle handling):
const settings = storage.defineItem('local:settings', {
fallback: { theme: 'light', notifications: true }
})
export default defineBackground(() => {
browser.runtime.onInstalled.addListener(async (details) => {
if (details.reason === 'install') {
// First install - initialize defaults
await settings.setValue({ theme: 'light', notifications: true })
await browser.tabs.create({ url: 'https://example.com/welcome' })
} else if (details.reason === 'update') {
// Extension updated - run migrations
const currentSettings = await settings.getValue()
if (!('notifications' in currentSettings)) {
await settings.setValue({ ...currentSettings, notifications: true })
}
}
})
browser.action.onClicked.addListener(async () => {
const currentSettings = await settings.getValue()
applyTheme(currentSettings.theme)
})
})Note: Always use storage.defineItem with fallback to handle cases where storage hasn't been initialized.
Reference: Chrome Runtime onInstalled
Use Keep-Alive Patterns for Long Operations
Service workers terminate after approximately 30 seconds of inactivity. For operations that take longer, use keep-alive patterns like periodic alarms or port connections.
Incorrect (terminates mid-operation):
export default defineBackground(() => {
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'PROCESS_LARGE_FILE') {
// Service worker may terminate during this 2-minute operation
processLargeFile(message.data).then(sendResponse)
return true
}
})
})Correct (keep-alive with port connection):
// background.ts - receives keep-alive port
export default defineBackground(() => {
browser.runtime.onConnect.addListener((port) => {
if (port.name === 'keepAlive') {
// Port connection keeps service worker alive while connected
port.onDisconnect.addListener(() => {
console.log('Keep-alive port disconnected')
})
}
})
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'PROCESS_LARGE_FILE') {
processLargeFile(message.data).then(sendResponse)
return true
}
})
})// popup.ts or content.ts - establishes keep-alive port
async function processWithKeepAlive(data: FileData) {
// Open port to keep service worker alive during long operation
const port = browser.runtime.connect({ name: 'keepAlive' })
try {
const result = await browser.runtime.sendMessage({
type: 'PROCESS_LARGE_FILE',
data
})
return result
} finally {
port.disconnect() // Release keep-alive when done
}
}Alternative (alarm-based keep-alive):
const KEEP_ALIVE_ALARM = 'keepAlive'
export default defineBackground(() => {
browser.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === KEEP_ALIVE_ALARM) {
// Alarm callback resets 30s termination timer
}
})
async function startLongOperation() {
await browser.alarms.create(KEEP_ALIVE_ALARM, { periodInMinutes: 0.4 })
try {
await processLargeFile()
} finally {
await browser.alarms.clear(KEEP_ALIVE_ALARM)
}
}
})When NOT to use this pattern:
- Operations that complete within 30 seconds
- Consider using offscreen documents for heavy DOM-dependent work instead
Reference: Chrome MV3 Service Worker Lifecycle
Use Offscreen Documents for DOM Operations
MV3 service workers cannot access DOM APIs. Use offscreen documents for operations requiring DOM like clipboard access, audio playback, or canvas manipulation.
Incorrect (DOM API in service worker):
export default defineBackground(() => {
browser.runtime.onMessage.addListener(async (message) => {
if (message.type === 'COPY_TO_CLIPBOARD') {
// Error: document is not defined in service worker
const textarea = document.createElement('textarea')
textarea.value = message.text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
}
})
})Correct (offscreen document with HTML entrypoint):
// entrypoints/background.ts
export default defineBackground(() => {
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'COPY_TO_CLIPBOARD') {
copyToClipboard(message.text).then(sendResponse)
return true
}
})
})
async function copyToClipboard(text: string) {
await setupOffscreenDocument()
await browser.runtime.sendMessage({
type: 'OFFSCREEN_COPY',
text
})
}
async function setupOffscreenDocument() {
const existingContexts = await browser.runtime.getContexts({
contextTypes: ['OFFSCREEN_DOCUMENT']
})
if (existingContexts.length > 0) return
await browser.offscreen.createDocument({
url: '/offscreen.html',
reasons: ['CLIPBOARD'],
justification: 'Copy text to clipboard'
})
}<!-- entrypoints/offscreen/index.html -->
<!doctype html>
<html>
<head>
<script type="module" src="./main.ts"></script>
</head>
</html>// entrypoints/offscreen/main.ts
browser.runtime.onMessage.addListener((message) => {
if (message.type === 'OFFSCREEN_COPY') {
navigator.clipboard.writeText(message.text)
}
})When NOT to use this pattern:
- Operations that don't require DOM APIs
- Content scripts that already have DOM access
Reference: Chrome Offscreen Documents
Register Event Listeners Synchronously
Service workers can terminate at any time and restart when events occur. Event listeners must be registered synchronously at the top level of the service worker to ensure they are active before any events fire.
Incorrect (async listener registration):
export default defineBackground(async () => {
const config = await loadConfig()
// Listener registered after async operation - may miss events
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
handleMessage(message, config).then(sendResponse)
return true
})
})Correct (synchronous registration, async handling):
export default defineBackground(() => {
// Listener registered synchronously - catches all events
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
loadConfig().then((config) => {
handleMessage(message, config).then(sendResponse)
})
return true
})
})When NOT to use this pattern:
- Event listeners that are intentionally conditional based on user settings
- Listeners that should only be active after explicit user action
Reference: WXT Background Entrypoints
Augment Browser Types for Missing APIs
Some browser APIs aren't in the default type definitions. Augment the types instead of using any or @ts-ignore.
Incorrect (bypassing type system):
export default defineBackground(() => {
// @ts-ignore - sidePanel types missing
browser.sidePanel.open({ windowId: 1 })
// Using 'any' loses all type safety
;(browser as any).sidePanel.setOptions({
path: 'sidepanel.html',
enabled: true
})
})Correct (type augmentation):
// types/browser.d.ts
declare namespace chrome {
export namespace readingList {
interface ReadingListEntry {
url: string
title: string
hasBeenRead: boolean
creationTime: number
}
export function addEntry(options: {
url: string
title: string
hasBeenRead?: boolean
}): Promise<void>
export function removeEntry(options: { url: string }): Promise<void>
export function query(options: {
url?: string
hasBeenRead?: boolean
}): Promise<ReadingListEntry[]>
}
}
// background.ts - now fully typed
export default defineBackground(() => {
browser.readingList.addEntry({
url: 'https://example.com',
title: 'Example',
hasBeenRead: false
})
})WXT v0.20+ type system: WXT now uses @types/chrome-based types instead of @types/webextension-polyfill. This means:
- Most Chrome APIs (including
sidePanel) are already typed - Augment the
chromenamespace directly, notwxt/browser - Use optional chaining for APIs not available on all browsers:
browser.sidePanel?.open()
When to augment:
- Experimental Chrome APIs not yet in
@types/chrome - Browser-specific APIs (Firefox-only, Safari-only)
- APIs behind flags (Chrome Origin Trials, etc.)
Reference: TypeScript Module Augmentation
Avoid any Type in Message Handlers
Message handlers using any miss type errors that cause silent runtime failures. Define explicit message types for compile-time safety.
Incorrect (any loses type safety):
export default defineBackground(() => {
browser.runtime.onMessage.addListener((message: any, sender, sendResponse) => {
if (message.type === 'FETCH_DATA') {
// No type checking - typos compile fine
fetchData(message.endpont).then(sendResponse) // 'endpont' typo not caught
return true
}
})
})Correct (discriminated union types):
// types/messages.ts
type FetchDataMessage = {
type: 'FETCH_DATA'
endpoint: string
params?: Record<string, string>
}
type UpdateSettingsMessage = {
type: 'UPDATE_SETTINGS'
settings: Partial<Settings>
}
type PingMessage = {
type: 'PING'
}
type ExtensionMessage = FetchDataMessage | UpdateSettingsMessage | PingMessage
// background.ts
export default defineBackground(() => {
browser.runtime.onMessage.addListener((
message: ExtensionMessage,
sender,
sendResponse
) => {
switch (message.type) {
case 'FETCH_DATA':
// TypeScript knows message.endpoint exists
fetchData(message.endpoint, message.params).then(sendResponse)
return true
case 'UPDATE_SETTINGS':
// TypeScript knows message.settings exists
updateSettings(message.settings).then(sendResponse)
return true
case 'PING':
sendResponse({ pong: true })
return false
}
})
})Note: The discriminated union pattern with switch statements enables TypeScript's exhaustiveness checking - add a new message type and TypeScript shows everywhere it needs handling.
Reference: TypeScript Discriminated Unions
Use import.meta for Environment and Build Info
WXT exposes build information through import.meta. Use these instead of hardcoding values or relying on runtime detection.
Incorrect (runtime detection and hardcoding):
export default defineBackground(() => {
// Hardcoded, needs manual update
const version = '1.2.3'
// Fragile runtime detection
const isDev = window.location.protocol === 'chrome-extension:'
&& !window.location.href.includes('production')
// Manual browser sniffing
const browser = navigator.userAgent.includes('Firefox') ? 'firefox' : 'chrome'
})Correct (import.meta.env):
export default defineBackground(() => {
// Version from package.json (auto-updated)
const version = browser.runtime.getManifest().version
// Build mode from Vite
const isDev = import.meta.env.DEV
const isProd = import.meta.env.PROD
const mode = import.meta.env.MODE // 'development' | 'production'
// Target browser from WXT
const targetBrowser = import.meta.env.BROWSER // 'chrome' | 'firefox' | 'edge' | 'safari'
const manifestVersion = import.meta.env.MANIFEST_VERSION // 2 | 3
if (isDev) {
console.log(`Running ${targetBrowser} dev build (MV${manifestVersion})`)
}
})Type declarations for custom env vars:
// types/env.d.ts
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_ANALYTICS_ID: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
// Usage
const apiUrl = import.meta.env.VITE_API_URLConditional code by browser:
if (import.meta.env.BROWSER === 'firefox') {
// Firefox-specific code
} else if (import.meta.env.BROWSER === 'chrome') {
// Chrome-specific code
}Reference: WXT Environment Variables
Use Path Aliases for Clean Imports
Configure path aliases to avoid deep relative imports. This improves readability and makes moving files safer during refactoring.
Incorrect (deep relative imports):
// entrypoints/popup/components/Settings.tsx
import { storage } from '../../../utils/storage'
import { formatDate } from '../../../utils/dates'
import type { Settings } from '../../../types/settings'
import { useSettings } from '../../../hooks/useSettings'
// Hard to read, breaks when files moveCorrect (path aliases):
// wxt.config.ts
export default defineConfig({
alias: {
'@': resolve(__dirname, './'),
'@/utils': resolve(__dirname, './utils'),
'@/types': resolve(__dirname, './types'),
'@/hooks': resolve(__dirname, './hooks'),
'@/components': resolve(__dirname, './components')
}
})
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["./*"],
"@/utils/*": ["./utils/*"],
"@/types/*": ["./types/*"],
"@/hooks/*": ["./hooks/*"],
"@/components/*": ["./components/*"]
}
}
}
// entrypoints/popup/components/Settings.tsx
import { storage } from '@/utils/storage'
import { formatDate } from '@/utils/dates'
import type { Settings } from '@/types/settings'
import { useSettings } from '@/hooks/useSettings'
// Clean, consistent, refactor-safeWXT default alias:
// WXT provides '@' alias by default pointing to project root
import { settings } from '@/utils/storage'Reference: WXT Configuration - Alias
Enable Strict Null Checks
Enable strict null checks in TypeScript to catch potential null reference errors. Extension APIs frequently return null for missing data.
Incorrect (null errors at runtime):
// tsconfig.json without strict null checks
{
"compilerOptions": {
"strict": false
}
}
// background.ts
export default defineBackground(() => {
browser.tabs.query({ active: true }, async (tabs) => {
const tab = tabs[0]
// Runtime error if no active tab
await browser.tabs.sendMessage(tab.id, { type: 'PING' })
})
})Correct (null safety enforced):
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true
}
}
// background.ts
export default defineBackground(() => {
browser.action.onClicked.addListener(async () => {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
// TypeScript requires null checks
if (!tab?.id) {
console.warn('No active tab found')
return
}
// tab.id is now guaranteed to be number
await browser.tabs.sendMessage(tab.id, { type: 'PING' })
})
})Common extension API null cases to handle:
browser.tabs.query()returns empty arraytab.idis undefined for some special tabstab.urlis undefined without tabs permissionsender.tabis undefined for messages from popup/backgroundstorage.getItem()returns null for missing keys
Reference: TypeScript Strict Mode
Type Entrypoint Options Explicitly
Use explicit types for entrypoint configuration to catch typos and invalid options at build time instead of runtime.
Incorrect (untyped configuration):
export default defineContentScript({
matches: ['*://*.example.com/*'],
runAt: 'documet_start', // Typo not caught
allframes: true, // Wrong casing not caught
main() {}
})Correct (typed configuration):
import type { ContentScriptDefinition } from 'wxt/sandbox'
const config: ContentScriptDefinition = {
matches: ['*://*.example.com/*'],
runAt: 'document_start', // TypeScript autocomplete helps
allFrames: true, // Correct casing enforced
}
export default defineContentScript({
...config,
main(ctx) {
// ctx is properly typed as ContentScriptContext
}
})Background script with type safety:
import type { BackgroundDefinition } from 'wxt/sandbox'
export default defineBackground({
persistent: false, // TypeScript shows this is MV2-only
type: 'module',
main() {
// Properly typed context
}
} satisfies BackgroundDefinition)Note: The satisfies keyword (TypeScript 4.9+) provides type checking while preserving the specific literal types.
Reference: WXT TypeScript Support
Use browser Namespace Over chrome
Use the browser namespace from WXT instead of chrome. WXT normalizes chrome to browser for cross-browser compatibility across Chrome, Firefox, Safari, and Edge.
Incorrect (chrome namespace):
export default defineBackground(() => {
async function pingActiveTab() {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (tab?.id) {
const response = await chrome.tabs.sendMessage(tab.id, { type: 'PING' })
return response
}
} catch (error) {
console.error('Failed to ping tab:', error)
}
}
chrome.action.onClicked.addListener(() => {
pingActiveTab()
})
})Correct (browser namespace):
export default defineBackground(() => {
async function pingActiveTab() {
try {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
if (tab?.id) {
const response = await browser.tabs.sendMessage(tab.id, { type: 'PING' })
return response
}
} catch (error) {
console.error('Failed to ping tab:', error)
}
}
browser.action.onClicked.addListener(() => {
pingActiveTab()
})
})Important (WXT v0.20+): WXT no longer uses webextension-polyfill. The browser global is a direct re-export of the native browser or chrome global. This means:
- Promise-based APIs work natively (Chrome 116+)
- For APIs that may not exist on all browsers, use optional chaining:
browser.runtime.onSuspend?.addListener(() => {}) browseris auto-imported by WXT -- no explicit import needed
Reference: WXT Extension APIs
Use #imports Virtual Module
WXT v0.20+ provides #imports as a unified virtual module for all WXT APIs. This replaces scattered imports from wxt/storage, wxt/sandbox, wxt/client, etc. WXT also auto-imports common utilities, so explicit imports are often unnecessary.
Incorrect (scattered old import paths):
import { storage } from 'wxt/storage'
import { defineContentScript } from 'wxt/sandbox'
import { ContentScriptContext } from 'wxt/client'
import { createShadowRootUi } from 'wxt/utils/content-script-ui/shadow-root'
export default defineContentScript({
matches: ['*://*/*'],
main(ctx: ContentScriptContext) {
const settings = await storage.getItem('local:settings')
}
})Correct (unified #imports or auto-imports):
// Option 1: Explicit imports from #imports
import { storage, createShadowRootUi } from '#imports'
// Option 2: Auto-imports (preferred) - no import needed
// WXT auto-imports: defineBackground, defineContentScript, browser, storage,
// createShadowRootUi, createIntegratedUi, createIframeUi, etc.
export default defineContentScript({
matches: ['*://*/*'],
main(ctx) {
// storage, browser, and ctx type are all auto-imported
const settings = await storage.getItem('local:settings')
}
})Setup: Run `wxt prepare` to generate type declarations:
# Generate .wxt/ directory with type declarations for #imports
wxt prepare
# Add to package.json scripts for new contributors
"scripts": {
"prepare": "wxt prepare"
}Testing caveat: vi.mock() must use actual import paths, not #imports:
// Incorrect
vi.mock('#imports', () => ({ storage: mockStorage }))
// Correct - mock the actual module path
vi.mock('wxt/storage', () => ({ storage: mockStorage }))Note: Auto-imports extend to files in components/, composables/, hooks/, and utils/ directories. Configure via imports in wxt.config.ts. Set imports: false to disable.
Reference: WXT Auto-Imports
Avoid Layout Thrashing in Content Scripts
Reading layout properties then writing causes forced synchronous reflows. Batch reads and writes separately to avoid layout thrashing on host pages.
Incorrect (interleaved reads and writes):
export default defineContentScript({
matches: ['*://*/*'],
main() {
const elements = document.querySelectorAll('.product-card')
elements.forEach((el) => {
// Read triggers layout
const height = el.offsetHeight
// Write invalidates layout
el.style.minHeight = `${height + 20}px`
// Next iteration: read triggers layout again
// N elements = N forced reflows
})
}
})Correct (batched reads then writes):
export default defineContentScript({
matches: ['*://*/*'],
main() {
const elements = document.querySelectorAll('.product-card')
// Batch all reads first
const heights = Array.from(elements).map((el) => el.offsetHeight)
// Then batch all writes
elements.forEach((el, i) => {
el.style.minHeight = `${heights[i] + 20}px`
})
// Only 1 reflow total
}
})Alternative (requestAnimationFrame for complex updates):
export default defineContentScript({
matches: ['*://*/*'],
main() {
const elements = document.querySelectorAll('.product-card')
// Read phase in current frame
const updates: Array<{ el: Element; height: number }> = []
elements.forEach((el) => {
updates.push({ el, height: el.offsetHeight })
})
// Write phase in next frame
requestAnimationFrame(() => {
updates.forEach(({ el, height }) => {
(el as HTMLElement).style.minHeight = `${height + 20}px`
})
})
}
})Layout-triggering properties to batch:
offsetWidth,offsetHeight,offsetTop,offsetLeftclientWidth,clientHeightscrollWidth,scrollHeight,scrollTop,scrollLeftgetComputedStyle(),getBoundingClientRect()
Reference: What forces layout/reflow
Clean Up UI on Unmount
Content script UIs must clean up event listeners, observers, and timers when removed. Use onRemove for cleanup and ctx helpers for auto-cleanup.
Incorrect (no cleanup):
export default defineContentScript({
matches: ['*://*/*'],
main(ctx) {
const ui = createShadowRootUi(ctx, {
name: 'my-panel',
position: 'inline',
anchor: 'body',
onMount: (container) => {
// Event listener never removed
window.addEventListener('resize', handleResize)
// Interval never cleared
setInterval(updateTime, 1000)
// MutationObserver never disconnected
const observer = new MutationObserver(handleMutation)
observer.observe(document.body, { childList: true })
}
})
ui.mount()
}
})Correct (cleanup via onRemove and ctx helpers):
export default defineContentScript({
matches: ['*://*/*'],
main(ctx) {
const ui = createShadowRootUi(ctx, {
name: 'my-panel',
position: 'inline',
anchor: 'body',
onMount: (container) => {
// ctx.addEventListener auto-removes on context invalidation
ctx.addEventListener(window, 'resize', handleResize)
const intervalId = ctx.setInterval(updateTime, 1000)
const observer = new MutationObserver(handleMutation)
observer.observe(document.body, { childList: true })
// Return resources that onRemove needs to clean up
return { observer }
},
onRemove: (mounted) => {
// mounted is the return value from onMount
mounted?.observer.disconnect()
}
})
ui.mount()
// Clean up UI when extension context invalidated (e.g., on update)
ctx.onInvalidated(() => {
ui.remove()
})
}
})With React (framework handles component cleanup):
export default defineContentScript({
matches: ['*://*/*'],
cssInjectionMode: 'ui',
async main(ctx) {
const ui = await createShadowRootUi(ctx, {
name: 'my-panel',
position: 'inline',
anchor: 'body',
onMount: (container) => {
const root = createRoot(container)
root.render(<Panel />)
return root
},
onRemove: (root) => {
root?.unmount()
}
})
ui.mount()
}
})Key pattern: onMount returns a value (app instance, resources object), onRemove receives that value for cleanup. Use ctx.addEventListener and ctx.setInterval for auto-cleanup on context invalidation.
Reference: WXT Content Script UI Lifecycle
Defer Popup Rendering Until Needed
Popups re-render every time they open. Defer heavy initialization until the popup is actually displayed to minimize perceived latency.
Incorrect (heavy initialization on load):
// popup/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { initializeAnalytics, loadUserData, syncState } from '@/utils'
// Runs on every popup open
const queryClient = new QueryClient()
initializeAnalytics() // 50ms
const userData = await loadUserData() // 100ms network
await syncState() // 150ms
ReactDOM.createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<App userData={userData} />
</QueryClientProvider>
)Correct (deferred initialization):
// popup/main.tsx
import { lazy, Suspense } from 'react'
// Lazy load heavy components
const App = lazy(() => import('./App'))
const LoadingSpinner = () => <div className="spinner" />
ReactDOM.createRoot(document.getElementById('root')!).render(
<Suspense fallback={<LoadingSpinner />}>
<App />
</Suspense>
)
// App.tsx - load data when component mounts
function App() {
const [isReady, setIsReady] = useState(false)
const [userData, setUserData] = useState<User | null>(null)
useEffect(() => {
// Initialize after first render (user sees UI skeleton)
Promise.all([
loadUserData(),
import('@/utils/analytics').then(m => m.initializeAnalytics())
]).then(([data]) => {
setUserData(data)
setIsReady(true)
})
}, [])
if (!isReady) return <PopupSkeleton />
return <MainContent userData={userData} />
}Reference: React Lazy Loading
Use Positioned Iframe for Complex UI
For complex UIs injected into pages, use a positioned iframe instead of rendering directly. This provides complete isolation and allows using full frameworks without affecting page performance.
Incorrect (heavy UI in content script):
export default defineContentScript({
matches: ['*://*/*'],
main(ctx) {
// Full React app in content script - bloats every page
const ui = createShadowRootUi(ctx, {
name: 'complex-ui',
onMount: (container) => {
const root = ReactDOM.createRoot(container)
root.render(
<QueryClientProvider client={queryClient}>
<ComplexDashboard /> {/* 200KB+ of components */}
</QueryClientProvider>
)
}
})
ui.mount()
}
})Correct (iframe for complex UI):
// entrypoints/content.ts - lightweight injector
export default defineContentScript({
matches: ['*://*/*'],
main(ctx) {
const ui = createIframeUi(ctx, {
page: '/dashboard.html', // Full page in extension context
position: 'inline',
anchor: 'body',
onMount: (wrapper, iframe) => {
// Style the iframe container
Object.assign(iframe.style, {
position: 'fixed',
bottom: '20px',
right: '20px',
width: '400px',
height: '500px',
border: 'none',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
zIndex: '2147483647'
})
}
})
browser.runtime.onMessage.addListener((message) => {
if (message.type === 'TOGGLE_DASHBOARD') {
ui.mounted ? ui.remove() : ui.mount()
}
})
}
})
// entrypoints/dashboard.html - full app runs in iframe
// Can use any framework, isolated from pageBenefits of iframe approach:
- Complete CSS isolation (no Shadow DOM complexity)
- Content script stays tiny (just injection logic)
- Full extension API access in iframe
- Page performance unaffected by heavy UI
Reference: WXT Iframe UI
Preserve Sidepanel State Across Opens
Unlike popups, sidepanels can persist state while open. Save state to storage on changes to survive sidepanel closes and browser restarts.
Incorrect (state lost on close):
// sidepanel/App.tsx
function App() {
// State lost every time sidepanel closes
const [searchQuery, setSearchQuery] = useState('')
const [selectedTab, setSelectedTab] = useState('search')
const [scrollPosition, setScrollPosition] = useState(0)
return (
<div>
<Tabs value={selectedTab} onChange={setSelectedTab} />
<SearchInput value={searchQuery} onChange={setSearchQuery} />
</div>
)
}Correct (state persisted to storage):
// utils/storage.ts
export const sidepanelState = storage.defineItem<SidepanelState>('session:sidepanelState', {
fallback: {
searchQuery: '',
selectedTab: 'search',
scrollPosition: 0
}
})
// sidepanel/App.tsx
function App() {
const [state, setState] = useState<SidepanelState | null>(null)
// Load state on mount
useEffect(() => {
sidepanelState.getValue().then(setState)
}, [])
// Save state on changes (debounced)
const debouncedSave = useMemo(
() => debounce((newState: SidepanelState) => {
sidepanelState.setValue(newState)
}, 300),
[]
)
const updateState = (updates: Partial<SidepanelState>) => {
const newState = { ...state, ...updates }
setState(newState)
debouncedSave(newState)
}
if (!state) return <Loading />
return (
<div>
<Tabs
value={state.selectedTab}
onChange={(tab) => updateState({ selectedTab: tab })}
/>
<SearchInput
value={state.searchQuery}
onChange={(query) => updateState({ searchQuery: query })}
/>
</div>
)
}Note: Use session: storage area for UI state that should clear on browser restart, local: for persistent preferences.
Reference: Chrome Side Panel API
Use Shadow DOM for Injected UI
When injecting UI into web pages from content scripts, use Shadow DOM to isolate your styles from the host page. This prevents both directions of style leakage.
Incorrect (styles leak between page and extension):
export default defineContentScript({
matches: ['*://*/*'],
main() {
const container = document.createElement('div')
container.innerHTML = `
<style>
.button { background: blue; } /* May conflict with page styles */
</style>
<button class="button">Click me</button>
`
document.body.appendChild(container)
// Page CSS for .button overrides extension styles
}
})Correct (Shadow DOM isolation with WXT helper):
import './panel.css'
export default defineContentScript({
matches: ['*://*/*'],
cssInjectionMode: 'ui',
main(ctx) {
const ui = createShadowRootUi(ctx, {
name: 'my-extension-panel',
position: 'inline',
anchor: 'body',
onMount: (container) => {
const button = document.createElement('button')
button.className = 'button'
button.textContent = 'Click me'
container.appendChild(button)
}
})
ui.mount()
}
})With framework (React example):
import './styles.css'
export default defineContentScript({
matches: ['*://*/*'],
cssInjectionMode: 'ui',
async main(ctx) {
const ui = await createShadowRootUi(ctx, {
name: 'my-extension-panel',
position: 'inline',
anchor: 'body',
onMount: (container) => {
const root = createRoot(container)
root.render(<Panel />)
return root
},
onRemove: (root) => root?.unmount()
})
ui.mount()
}
})WXT v0.20+ CSS reset: Shadow roots now apply all: initial to reset inherited styles automatically. Use inheritStyles: true to preserve page style inheritance if needed (rare).
Event isolation: Use isolateEvents to prevent click/keyboard events from bubbling to the host page:
createShadowRootUi(ctx, {
name: 'my-panel',
position: 'overlay',
isolateEvents: ['click', 'keydown']
})Reference: WXT Content Script UI
Related skills
How it compares
Pick wxt-browser-extensions for WXT-specific agent guardrails instead of generic Chrome extension tutorials.
FAQ
What framework does wxt-browser-extensions target?
wxt-browser-extensions targets the WXT browser extension framework, providing TypeScript rule cards with incorrect and correct patterns for building modern Chrome and Firefox extensions.
How are rules prioritized in wxt-browser-extensions?
wxt-browser-extensions labels each rule with an impact tier from CRITICAL to LOW and includes quantified impact descriptions, such as multiplicative performance improvements or data-loss prevention guidance.
Is Wxt Browser Extensions safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.