# How CRXJS Hot Module Replacement Works with Content Scripts: A Deep Dive

> Explore how CRXJS Hot Module Replacement injects updated content script bundles via WebSocket for lightning-fast Chrome extension development. Learn the internal HMR process.

- Repository: [crxjs/chrome-extension-tools](https://github.com/crxjs/chrome-extension-tools)
- Tags: deep-dive
- Published: 2026-02-28

---

**CRXJS extends Vite's HMR system to enable hot reloading of Chrome extension content scripts by intercepting WebSocket messages, detecting file changes, and injecting updated bundles via a runtime loader, while forcing full extension reloads for background script changes.**

CRXJS (chrome-extension-tools) bridges the gap between Vite's fast development server and Chrome's extension architecture. When developing browser extensions, **CRXJS Hot Module Replacement** allows content scripts—the JavaScript injected into web pages—to update instantly without requiring a full browser extension reload, preserving the rapid feedback loop that makes Vite productive.

## The Architecture of CRXJS HMR for Content Scripts

The system operates through a specialized pipeline that intercepts Vite's standard HMR flow and adds Chrome-extension-specific handling. This process involves server-side message decoration, module change detection, and runtime bundle injection.

### Server-Side WebSocket Decoration

The HMR plugin first decorates Vite's WebSocket `send` method to maintain a dual-channel communication system. Located in [`src/node/plugin-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/node/plugin-hmr.ts) (lines 57-73), this decoration forwards standard Vite HMR payloads unchanged while simultaneously emitting a custom `"crx:content-script-payload"` event. This approach preserves Vite's native HMR pipeline while providing the extension runtime with a dedicated hook for error handling and content-script-specific messages.

### Detecting Changed Modules

When Vite notifies the plugin of a hot update via `handleHotUpdate` (beginning at line 88 in [`src/node/plugin-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/node/plugin-hmr.ts)), the system collects changed modules into three distinct groups:

- **Relative files** (`relFiles`): Project files imported by the content script
- **File-system files** (`fsFiles`): Modules resolved through Vite's file system
- **Virtual modules**: Generated modules such as UnoCSS CSS that don't exist on disk

This classification occurs at lines 88-104 and enables the plugin to distinguish between physical source files and synthetic dependencies.

### Content Script Update Strategy

For regular content scripts, the plugin determines whether to trigger an update by checking if the changed file is the script itself or one of its direct imports. This logic at lines 59-68 in [`src/node/plugin-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/node/plugin-hmr.ts) examines both `relFiles` and the module graph (`modules.some(isImporter...)`). When matches occur, the plugin calls `update` on every changed file plus any associated virtual modules, ensuring the browser receives the fresh bundle without page reload.

### Manifest-Declared CSS Handling

Content scripts often include CSS declared directly in the manifest. CRXJS handles these through synthetic virtual modules. When a CSS file declared in the manifest changes, the system:

1. Creates a virtual module ID following the pattern `/@crx/content-css/<index>`
2. Resolves this in [`src/node/plugin-contentScripts_declared.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/node/plugin-contentScripts_declared.ts) (lines 95-125) as a set of `import "<path>"` statements
3. Updates the CSS files and rewrites the synthetic module when changes occur (lines 35-57 in [`src/node/plugin-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/node/plugin-hmr.ts))

This virtual module approach allows manifest-declared CSS to participate in HMR exactly like JavaScript modules.

## Runtime Injection and the Content Script Loader

The final piece of the HMR puzzle occurs inside the browser. When CRXJS builds a content script, it appends a tiny runtime loader located at [`src/client/iife/content-pro-main-loader.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/client/iife/content-pro-main-loader.ts). This 10-line IIFE wrapper:

- Imports the user-exported `onExecute` function from the module API
- Invokes `onExecute` with performance metrics
- Runs inside the content script's isolated world every time the script injects

Because the loader executes on every injection, when Vite pushes a fresh bundle via HMR, the browser simply evaluates the new loader code. This loader then imports and executes the updated module, instantly reflecting changes without requiring a full extension reload or page refresh.

## Code Examples

### Simple Content Script with HMR

Enable hot reloading in your content script by exporting `onExecute` and optionally handling HMR events:

```typescript
// src/content/main.ts
export function onExecute({ perf }: { perf: { injectTime: number; loadTime: number } }) {
  console.log('[CRXJS] Content script loaded', perf);
}

// Enable Vite HMR inside the content script (optional)
if (import.meta.hot) {
  import.meta.hot.accept(() => {
    console.log('[CRXJS] Hot‑updated content script');
  });
}

```

When built, CRXJS wraps this file with [`content-pro-main-loader.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/content-pro-main-loader.ts), which calls `onExecute` automatically. The HMR hook allows you to handle custom logic during updates.

### Manifest-Declared CSS with HMR

Declare CSS in your manifest for automatic bundling and hot reloading:

```json
// manifest.json (MV3)
{
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["src/content/main.ts"],
      "css": ["src/styles/content.css"]
    }
  ]
}

```

The plugin creates a virtual module `/@crx/content-css/0` containing:

```typescript
import "/src/styles/content.css";

```

When [`src/styles/content.css`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/styles/content.css) changes, the HMR system updates both the physical file and the virtual module, injecting the new styles without reloading the extension.

### Background Script Change Forces Full Reload

Background scripts cannot be hot-replaced due to Chrome's architecture. When you modify a background script:

```typescript
// src/background/main.ts
chrome.runtime.onInstalled.addListener(() => {
  console.log('Extension installed');
});

```

The plugin detects this change in `handleHotUpdate` (lines 21-31 in [`src/node/plugin-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/node/plugin-hmr.ts)) and sends the `crxRuntimeReload` payload. This triggers a complete extension reload, which is necessary for background changes to take effect.

## Key Implementation Files

Understanding the CRXJS HMR system requires familiarity with these specific source files:

| File | Role | Location |
|------|------|----------|
| [`src/node/plugin-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/node/plugin-hmr.ts) | Core HMR plugin that decorates Vite's WebSocket, handles hot updates, and determines whether to reload content scripts or the entire extension. | [packages/vite-plugin/src/node/plugin-hmr.ts](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/plugin-hmr.ts) |
| [`src/node/plugin-contentScripts_declared.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/node/plugin-contentScripts_declared.ts) | Generates virtual modules for manifest-declared CSS and registers them for HMR resolution. | [packages/vite-plugin/src/node/plugin-contentScripts_declared.ts](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/plugin-contentScripts_declared.ts) |
| [`src/node/virtualFileIds.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/node/virtualFileIds.ts) | Defines virtual-module ID schemes (`/@crx/content-css/<index>`) and provides type-guards for virtual file resolution. | [packages/vite-plugin/src/node/virtualFileIds.ts](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/virtualFileIds.ts) |
| [`src/client/iife/content-pro-main-loader.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/client/iife/content-pro-main-loader.ts) | Runtime IIFE loader appended to content scripts; imports and executes `onExecute` on every injection to enable HMR. | [packages/vite-plugin/src/client/iife/content-pro-main-loader.ts](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/client/iife/content-pro-main-loader.ts) |
| [`src/node/plugin-manifest.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/node/plugin-manifest.ts) | Inserts synthetic CSS virtual modules into the manifest during serve mode. | [packages/vite-plugin/src/node/plugin-manifest.ts](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/plugin-manifest.ts) |
| [`src/node/fileWriter.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/node/fileWriter.ts) | Writes updated bundle files to the dev-out directory when HMR updates occur. | [packages/vite-plugin/src/node/fileWriter.ts](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/fileWriter.ts) |

These files collectively give CRXJS developers a seamless "live-reload" experience for content scripts, mirroring the fast feedback loop that Vite provides for regular web apps, while respecting Chrome-extension constraints.

## Summary

- **CRXJS Hot Module Replacement** extends Vite's HMR system to support Chrome extension content scripts through a specialized plugin architecture.
- The system decorates Vite's WebSocket in [`src/node/plugin-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/node/plugin-hmr.ts) to forward HMR payloads while emitting custom `"crx:content-script-payload"` events for extension-specific handling.
- Changed modules are categorized into relative files, file-system files, and virtual modules during the `handleHotUpdate` hook (lines 88-104).
- Content scripts receive hot updates via the `update` mechanism, while background script changes trigger a full extension reload through the `crxRuntimeReload` payload.
- Manifest-declared CSS uses virtual modules (`/@crx/content-css/<index>`) to participate in HMR, resolved in [`src/node/plugin-contentScripts_declared.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/node/plugin-contentScripts_declared.ts).
- The runtime loader at [`src/client/iife/content-pro-main-loader.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/client/iife/content-pro-main-loader.ts) executes updated modules by importing `onExecute` on every injection, completing the HMR cycle.

## Frequently Asked Questions

### How does CRXJS HMR differ from standard Vite HMR?

Standard Vite HMR updates modules in a single web page context, but **CRXJS Hot Module Replacement** must account for Chrome's isolated worlds and extension architecture. CRXJS decorates Vite's WebSocket to emit custom `"crx:content-script-payload"` events and uses a runtime IIFE loader to re-execute content scripts in their isolated context. Additionally, CRXJS forces full extension reloads for background script changes, whereas Vite would simply update the module.

### What triggers a full extension reload instead of hot module replacement?

Changes to **background scripts** or any of their imports trigger a full extension reload rather than HMR. In [`src/node/plugin-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/node/plugin-hmr.ts) (lines 21-31), the plugin detects background script changes and sends a `crxRuntimeReload` payload to the browser. This is necessary because Chrome does not support hot-swapping background service workers or persistent background pages; the entire extension context must be destroyed and recreated.

### How does CRXJS handle CSS hot reloading for content scripts?

CRXJS creates **virtual modules** for CSS files declared in the manifest. When a content script includes CSS via the manifest's `css` array, the plugin generates a virtual module ID like `/@crx/content-css/0` that imports the physical CSS file. During `handleHotUpdate`, changes to CSS files trigger updates to both the physical file and its virtual module wrapper, allowing the browser to hot-reload styles without reloading the extension.

### Can I use import.meta.hot in content scripts?

Yes, you can use `import.meta.hot` in content scripts for custom HMR handling. The **CRXJS Hot Module Replacement** system preserves Vite's `import.meta.hot` API within content scripts. When a module updates, the runtime loader ([`src/client/iife/content-pro-main-loader.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/client/iife/content-pro-main-loader.ts)) re-executes the module, triggering any `import.meta.hot.accept()` callbacks you have defined. This allows you to implement custom cleanup or state preservation logic during hot updates.