# How the Custom HMR Plugin (@extension/hmr) Enables Hot Reload for Chrome Extensions

> Discover how the custom HMR plugin provides a lightweight WebSocket system for automatic hot reloading of Chrome extension scripts and UI pages as you code, speeding up development.

- Repository: [JongHak Seo/chrome-extension-boilerplate-react-vite](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite)
- Tags: internals
- Published: 2026-03-05

---

**The @extension/hmr package provides a lightweight WebSocket-based Hot Module Replacement system that automatically reloads Chrome extension background scripts, content scripts, and UI pages the moment source files change, eliminating manual refreshes during development.**

The `jonghakseo/chrome-extension-boilerplate-react-vite` repository ships with this purpose-built HMR solution to overcome Chrome's unique extension constraints. Unlike standard Vite HMR designed for traditional web apps, this custom plugin handles Manifest V3 service workers and aggressively cached content scripts through a dedicated WebSocket dev server and targeted runtime injections.

## Architecture of the @extension/hmr System

The custom HMR implementation consists of three coordinated layers that bridge Vite's build pipeline with the Chrome extension runtime.

### WebSocket Communication Layer

At the core of the system is a lightweight bidirectional messaging layer defined in [`packages/hmr/lib/consts.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/packages/hmr/lib/consts.ts). The **WebSocket server** runs alongside the Vite dev server, while **client sockets** live inside the extension bundles.

- **[`watch-rebuild-plugin.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/watch-rebuild-plugin.ts)** (lines 48‑59): Implements the `closeBundle` Vite hook to open a socket to `LOCAL_RELOAD_SOCKET_URL` and broadcast a `BUILD_COMPLETE` message containing a unique HMR ID.
- **[`init-client.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/init-client.ts)**: Opens a persistent WebSocket connection from within the extension runtime, listens for `DO_UPDATE` commands, executes the registered update handler, and acknowledges with `DONE_UPDATE`.

### Runtime Injection Scripts

Because Chrome extensions run in isolated contexts (background service workers, content scripts, popup pages), the system injects specialized reload logic rather than relying on Vite's standard module replacement.

- **[`reload.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/reload.ts)**: Calls `chrome.runtime.reload()` to restart the entire extension. Ideal for background scripts and service workers.
- **[`refresh.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/refresh.ts)**: Performs `window.location.reload()` on the active tab, deferring execution while the tab is hidden to prevent data loss. Used for content scripts and UI pages.

Both modules import `initClient` and register an `onUpdate` callback that triggers the appropriate reload mechanism when the WebSocket signals an update.

### Vite Plugin Orchestration

Three custom plugins wire the build pipeline to the HMR runtime:

- **`watchRebuildPlugin`**: Injects the selected injection script ([`reload.js`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/reload.js) or [`refresh.js`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/refresh.js)) into every bundle via a wrapper (lines 61‑65 of [`watch-rebuild-plugin.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/watch-rebuild-plugin.ts)). Accepts a `reload: boolean` option to choose between extension reload and page refresh.
- **`watchPublicPlugin`**: Adds every file under `public/` to Vite's watch list, ensuring static assets like icons or HTML files trigger rebuilds and subsequent hot reloads.
- **`makeEntryPointPlugin`**: Generates an additional [`_dev.js`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/_dev.js) entry point for each content script and rewrites the generated chunk to import that file. This bypasses Chrome's aggressive content-script caching and supports Firefox through an `IS_FIREFOX` path check.

## Step-by-Step Hot Reload Flow

The custom HMR plugin coordinates the following sequence every time you save a file:

1. **Development Build Starts**: When `vite` runs with `IS_DEV` enabled, `chrome-extension/vite.config.mts` registers the HMR plugins:

```typescript
// chrome-extension/vite.config.mts
import { watchRebuildPlugin, watchPublicPlugin } from '@extension/hmr';
// ...
plugins: [
  libAssetsPlugin({ outputPath: outDir }),
  watchPublicPlugin(),
  makeManifestPlugin({ outDir }),
  IS_DEV && watchRebuildPlugin({ reload: true, id: 'chrome-extension-hmr' }),
],

```

2. **Build Completion Signals Server**: Upon finishing the bundle, `watchRebuildPlugin`'s `closeBundle` hook sends a `BUILD_COMPLETE` message to the locally running reload server (started via `pnpm run dev`), including the unique plugin `id`.

3. **Server Broadcasts Update**: The reload server receives the message and forwards a `DO_UPDATE` command to all connected clients listening for that specific ID, preventing cross-talk between multiple independent builds (e.g., side-panel or devtools pages).

4. **Extension Runtime Handles Update**: Each bundle contains the wrapper code injected at lines 61‑65 of [`watch-rebuild-plugin.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/watch-rebuild-plugin.ts), which loads the appropriate injection script ([`reload.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/reload.ts) or [`refresh.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/refresh.ts)). The injection script initializes the client with `initClient({ id, onUpdate })`, opens its WebSocket, and awaits the signal.

5. **Instant Reload Executes**: When `DO_UPDATE` arrives, the callback invokes either `chrome.runtime.reload()` for the background or `window.location.reload()` for content scripts, delivering the updated code without manual intervention in `chrome://extensions`.

## Configuring HMR in Extension Builds

### Enabling Extension Reload for Background Scripts

Set `reload: true` to inject the [`reload.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/reload.ts) script, which restarts the entire extension when any file changes:

```typescript
// chrome-extension/vite.config.mts
import { defineConfig } from 'vite';
import { watchRebuildPlugin, watchPublicPlugin } from '@extension/hmr';
import makeManifestPlugin from './utils/plugins/make-manifest-plugin.js';
import env, { IS_DEV } from '@extension/env';

export default defineConfig({
  plugins: [
    watchPublicPlugin(),
    makeManifestPlugin({ outDir }),
    IS_DEV && watchRebuildPlugin({ reload: true, id: 'chrome-extension-hmr' }),
  ],
});

```

### Enabling Page Refresh for Content Scripts

For content scripts that need to refresh the host page rather than the extension, set `reload: false`:

```typescript
// pages/content/build.mts
import { defineConfig } from 'vite';
import { watchRebuildPlugin } from '@extension/hmr';

export default defineConfig({
  plugins: [
    watchRebuildPlugin({ reload: false, id: 'content-script-hmr' }),
  ],
});

```

### Manual Client Initialization (Advanced)

For rare cases requiring custom update logic, import `initClient` directly:

```typescript
// Inside any extension script
import initClient from '@extension/hmr/lib/initializers/init-client.js';

initClient({
  id: 'my-custom-context',
  onUpdate: () => {
    console.log('Code updated');
    window.location.reload();
  },
});

```

## Key Source Files and Their Roles

- **[`packages/hmr/lib/consts.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/packages/hmr/lib/consts.ts)**: Defines `LOCAL_RELOAD_SOCKET_URL` and message type constants (`BUILD_COMPLETE`, `DO_UPDATE`, `DONE_UPDATE`).
- **[`packages/hmr/lib/plugins/watch-rebuild-plugin.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/packages/hmr/lib/plugins/watch-rebuild-plugin.ts)**: Core Vite plugin that injects HMR wrappers (lines 61‑65) and signals the reload server via `closeBundle` (lines 48‑59).
- **[`packages/hmr/lib/plugins/watch-public-plugin.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/packages/hmr/lib/plugins/watch-public-plugin.ts)**: Watches the `public/` directory for static asset changes.
- **[`packages/hmr/lib/plugins/make-entry-point-plugin.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/packages/hmr/lib/plugins/make-entry-point-plugin.ts)**: Creates [`_dev.js`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/_dev.js) entry points for content script cache busting.
- **[`packages/hmr/lib/injections/reload.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/packages/hmr/lib/injections/reload.ts)**: Triggers full extension reload via `chrome.runtime.reload()`.
- **[`packages/hmr/lib/injections/refresh.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/packages/hmr/lib/injections/refresh.ts)**: Triggers page reload with visibility deferral.
- **[`packages/hmr/lib/initializers/init-client.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/packages/hmr/lib/initializers/init-client.ts)**: Establishes the WebSocket connection inside the extension runtime and routes update commands.

## Summary

- The **@extension/hmr** package replaces Vite's native HMR with a WebSocket-based system tailored for Chrome extension architecture.
- **`watchRebuildPlugin`** coordinates builds by sending `BUILD_COMPLETE` signals and injecting runtime handlers into every bundle.
- **Injection scripts** ([`reload.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/reload.ts) and [`refresh.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/refresh.ts)) handle the distinct requirements of service workers versus content scripts.
- **`makeEntryPointPlugin`** solves content-script caching by generating dynamic [`_dev.js`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/_dev.js) entry points.
- The system supports **multiple concurrent builds** through unique HMR IDs, enabling simultaneous development of background, popup, and side-panel contexts.

## Frequently Asked Questions

### How does this custom HMR system differ from Vite's native HMR?

Standard Vite HMR replaces modules in a running browser tab using ES module imports, but Chrome extensions run in isolated contexts with persistent service workers and cached content scripts. The custom HMR plugin uses WebSockets to trigger full `chrome.runtime.reload()` or page refreshes instead of in-place module replacement, ensuring Manifest V3 compliance and bypassing Chrome's aggressive script caching.

### Why does the system use WebSockets instead of Chrome's native messaging?

WebSockets provide a bidirectional communication channel between the Node.js dev server and the extension runtime that works uniformly across background scripts, content scripts, and UI pages. Chrome's `chrome.runtime.sendMessage` API requires complex port management between contexts and fails when the background service worker restarts, whereas the WebSocket connection in [`init-client.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/init-client.ts) re-establishes automatically and handles the `DO_UPDATE` signal reliably.

### How does the plugin handle cache-busting for content scripts?

The **`makeEntryPointPlugin`** generates a secondary [`_dev.js`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/_dev.js) file for every content script entry point and rewrites the main bundle to import that file. Because the [`_dev.js`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/_dev.js) path changes dynamically with each build, Chrome treats it as a new resource, forcing a fetch of the latest code rather than serving the cached version. Firefox support is handled through a conditional `IS_FIREFOX` path check within the same plugin.

### Can I use different reload strategies for background and content scripts?

Yes. The `reload` option in `watchRebuildPlugin` controls the injection strategy per build. Set `reload: true` for background scripts to trigger `chrome.runtime.reload()`, and `reload: false` for content scripts to trigger page refreshes via [`refresh.ts`](https://github.com/jonghakseo/chrome-extension-boilerplate-react-vite/blob/main/refresh.ts). Each build can also specify a unique `id` parameter to ensure background updates do not trigger content script refreshes and vice versa.