# How Content Script HMR Loaders Enable Hot Module Replacement in Chrome Extensions

> Learn how crxjs content script HMR loaders enable hot module replacement in Chrome extensions. Instantly update modules without full reloads.

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

---

**Client-side HMR loaders wrap content scripts in an asynchronous IIFE that dynamically imports Vite's HMR client and establishes a Chrome runtime Port, forwarding WebSocket payloads to enable instant module updates without reloading the entire extension.**

When developing Chrome extensions with **crxjs/vite-plugin**, the development server leverages specialized loaders to inject hot module replacement (HMR) capabilities directly into content scripts. The file [`content-dev-loader.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/content-dev-loader.ts) serves as the critical bootstrapper that bridges Vite's standard HMR runtime with the isolated execution context of browser extension content scripts, allowing developers to see code changes instantly while the background service worker remains active.

## The Dev Loader Template Architecture

At the heart of the system lies [`packages/vite-plugin/src/client/iife/content-dev-loader.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/client/iife/content-dev-loader.ts), a template that compiles into an Immediately Invoked Function Expression (IIFE) injected as the actual content script entry point during development. This loader orchestrates three sequential operations: executing an optional preamble, initializing the Vite HMR client, and mounting the real content script module.

The template relies on three string placeholders replaced at build time:

- `__PREAMBLE__`: Path to framework-specific HMR helpers (e.g., React's Fast Refresh runtime)
- `__CLIENT__`: Virtual module ID `/@vite/client` containing Vite's HMR client
- `__SCRIPT__`: Relative path to the actual content script source file

```typescript
// packages/vite-plugin/src/client/iife/content-dev-loader.ts
declare const __PREAMBLE__: string
declare const __CLIENT__: string
declare const __SCRIPT__: string

const injectTime = performance.now()
;(async () => {
  // ① Framework preamble (optional)
  if (__PREAMBLE__) await import(/* @vite-ignore */ chrome.runtime.getURL(__PREAMBLE__))

  // ② Vite HMR client setup
  await import(/* @vite-ignore */ chrome.runtime.getURL(__CLIENT__))

  // ③ Content script execution with performance metrics
  const { onExecute } = await import(/* @vite-ignore */ chrome.runtime.getURL(__SCRIPT__)) as ContentScriptAPI.ModuleExports
  onExecute?.({ perf: { injectTime, loadTime: performance.now() - injectTime } })
})().catch(console.error)

export {}

```

The `onExecute` hook exported by your content script serves as the deterministic entry point, receiving an object containing `injectTime` and `loadTime` performance measurements. When HMR triggers an update, the loader re-imports the module and invokes `onExecute` again with fresh timing data.

## Generating the Loader at Build Time

The `createDevLoader` function in [`packages/vite-plugin/src/node/contentScripts.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/contentScripts.ts) transforms the template into executable JavaScript by replacing placeholders with concrete file names. This function accepts an object containing `preamble`, `client`, and `fileName` strings, performing simple string replacement to produce the final loader asset.

```typescript
// packages/vite-plugin/src/node/contentScripts.ts
export function createDevLoader({
  preamble,
  client,
  fileName,
}: {
  preamble: string
  client: string
  fileName: string
}): string {
  return contentDevLoader
    .replace(/__PREAMBLE__/g, JSON.stringify(preamble))
    .replace(/__CLIENT__/g, JSON.stringify(client))
    .replace(/__SCRIPT__/g, JSON.stringify(fileName))
    .replace(/__TIMESTAMP__/g, JSON.stringify(Date.now()))
}

```

The function returns a valid JavaScript string where `JSON.stringify()` ensures proper escaping of file paths. The resulting code is emitted as a virtual asset with a generated filename (e.g., [`src/content/main-loader.js`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/content/main-loader.js)), which the manifest registers as the actual content script entry point during development.

## Integrating with the Vite Build Graph

The `pluginContentScripts` module registers these virtual files with Rollup through the `add()` helper function, ensuring the loader and its dependencies become part of the module graph. When processing content script registrations, the plugin emits three distinct virtual modules:

1. **Preamble module** (optional React/Preact HMR setup)
2. **Vite client module** (`/@vite/client`)
3. **Loader asset** (the generated IIFE wrapper)

```typescript
// packages/vite-plugin/src/node/plugin-contentScripts.ts (excerpt)
if (type === 'loader') {
  let preamble = { fileName: '' }
  if (preambleCode) preamble = add({ type: 'module', id: preambleId })

  const client = add({ type: 'module', id: viteClientId })
  const file   = add({ type: 'module', id })

  const loader = add({
    type: 'asset',
    id: getFileName({ type: 'loader', id }),
    source: createDevLoader({
      preamble: preamble.fileName,
      client:   client.fileName,
      fileName: file.fileName,
    }),
  })
  script.fileName = loader.fileName
}

```

Here, `viteClientId` resolves to the virtual module `/@vite/client`, while `preambleId` maps to `/@crx/preamble`. The `script.fileName` assignment ensures Chrome loads the wrapper script rather than the raw source file, establishing the HMR pipeline before user code executes.

## The HMR Runtime Bridge

Because content scripts cannot directly access Vite's WebSocket connection, the system employs [`packages/vite-plugin/src/client/es/hmr-content-port.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/client/es/hmr-content-port.ts) to forward HMR payloads through Chrome's extension messaging API. The `HMRPort` class creates a persistent `chrome.runtime.Port` connection to the background service worker, which receives WebSocket messages from the dev server and relays them to the content script.

```typescript
// packages/vite-plugin/src/client/es/hmr-content-port.ts
declare const __CRX_HMR_TIMEOUT__: number

export class HMRPort {
  private port: chrome.runtime.Port | undefined
  private callbacks = new Map<string, Set<(event: any) => void>>()

  constructor() {
    // Keep-alive ping and reconnection logic
    setInterval(() => {
      try { this.port?.postMessage({ data: 'ping' }) }
      catch (e) { if (e.message.includes('Extension context invalidated.')) location.reload() }
    }, __CRX_HMR_TIMEOUT__)
    setInterval(this.initPort, 5 * 60 * 1000)
    this.initPort()
  }

  initPort = () => {
    this.port?.disconnect()
    this.port = chrome.runtime.connect({ name: '@crx/client' })
    this.port.onMessage.addListener(this.handleMessage.bind(this))
  }

  handleMessage = (message: any) => {
    const payload = JSON.parse(message.data)
    if (payload.type === 'custom' && payload.event.startsWith('crx:')) {
      if (payload.event === 'crx:runtime-reload')
        setTimeout(() => location.reload(), 500)
      else
        this.forward(JSON.stringify(payload.data))
    } else {
      this.forward(message.data) // Standard Vite HMR updates
    }
  }
}

```

The `handleMessage` method distinguishes between standard Vite HMR payloads (forwarded directly to the client) and custom `crx:` events. When the dev server signals a full runtime reload via `crx:runtime-reload`, the bridge triggers a page reload after a 500ms delay to ensure a clean state.

## End-to-End Hot Module Replacement Flow

The complete HMR lifecycle for content scripts involves coordinated communication between the dev server, background service worker, and the client-side loader:

1. **File change detection**: Vite detects modifications to a module imported by the content script
2. **WebSocket broadcast**: The dev server pushes an HMR update payload via WebSocket to the background service worker (handled in [`plugin-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-hmr.ts))
3. **Port forwarding**: The background worker transmits the payload through the `chrome.runtime.Port` established by `HMRPort`
4. **Client processing**: The loader's imported `/@vite/client` receives the forwarded message via `HMRPort.handleMessage`
5. **Module re-execution**: Vite's HMR runtime invalidates the old module and re-imports it, triggering the `onExecute` hook with updated code
6. **State preservation**: The content script updates without reloading the extension or refreshing the host page

**Important limitation**: Content scripts configured to run in the `MAIN` world (executing in the page's JavaScript context rather than the isolated world) cannot utilize this HMR system. For these scripts, the plugin falls back to static file generation and emits a console warning, as the `MAIN` world lacks access to `chrome.runtime` APIs required for the port communication.

## Summary

- **[`content-dev-loader.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/content-dev-loader.ts)** acts as an IIFE wrapper that imports the Vite HMR client and the actual content script module, exposing an `onExecute` lifecycle hook.
- **`createDevLoader`** generates the final loader code by replacing template placeholders (`__PREAMBLE__`, `__CLIENT__`, `__SCRIPT__`) with specific file paths at build time.
- **`pluginContentScripts`** integrates these virtual modules into Rollup's build graph, ensuring the loader becomes the registered entry point in the extension manifest.
- **[`hmr-content-port.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/hmr-content-port.ts)** bridges the gap between Vite's WebSocket server and content scripts by forwarding HMR payloads through a `chrome.runtime.Port` connection to the background worker.
- **Main-world limitation**: Content scripts running in `world: 'MAIN'` cannot use HMR and fall back to static builds due to restricted extension API access.

## Frequently Asked Questions

### Why does the loader use `chrome.runtime.getURL()` for imports?

Content scripts execute in isolated contexts where standard relative imports fail. The `chrome.runtime.getURL()` API converts relative paths into absolute `chrome-extension://` URLs, ensuring the browser can locate virtual modules like `/@vite/client` and the preamble scripts stored in the extension's package.

### What happens if the background service worker disconnects during HMR?

The `HMRPort` class implements automatic reconnection logic with a keep-alive ping interval defined by `__CRX_HMR_TIMEOUT__`. If the port disconnects (e.g., due to service worker termination), `initPort` recreates the connection. If the extension context becomes invalid, the code triggers a full `location.reload()` to restore the development environment.

### Can I use HMR with content scripts that don't export `onExecute`?

While the loader attempts to call `onExecute?.()`, omitting this export prevents the script from receiving performance metrics and may cause errors if your code expects specific initialization timing. Although the module will still hot-reload due to Vite's underlying HMR, relying on side effects at the top level of your module rather than inside `onExecute` can lead to memory leaks or duplicate event listeners during updates.

### Does this HMR system work in production builds?

No, the [`content-dev-loader.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/content-dev-loader.ts) and `HMRPort` infrastructure are exclusively development features. Production builds use [`content-prod-loader.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/content-prod-loader.ts) (or direct source files) without the WebSocket client or runtime port connections, resulting in static, optimized JavaScript bundles suitable for the Chrome Web Store.