Hot Reloading an Extension During Development

Cut the MV3 edit-reload loop to seconds — watch builds, a development-only reload channel into the service worker, reloading open tabs' content scripts, and keeping it out of release builds.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

The default extension development loop is: save, run the build, open chrome://extensions, click reload, find the tab you were testing, reload it too, reopen the popup. It takes twenty seconds and it is repeated hundreds of times a day. Automating it is not glamorous and it is the single largest productivity improvement available — provided the automation never ships, because a reload client left in a release build is both a review rejection and a remote-control channel. This guide is part of build tooling and bundlers.

What “reload” needs to cover

Reloading the extension restarts the worker and reloads extension pages, but content scripts already injected into open tabs keep running the old code, now disconnected from the extension. A complete reload has three parts.

A complete development reloadA file save triggers a rebuild; the dev server signals the extension; the worker reloads the extension, then re-injects or reloads the tabs running its content scripts.Save a fileeditorWatch buildwrites dist/NotifyWebSocket on localhostinside the extensionruntime.reload()worker + pages restartFind tabs with our scripttabs.query by matchReload those tabsfresh content script
Skipping the last box is why a reload often seems not to have worked — the page is still running last build's content script.

Step-by-step

1. Run the build in watch mode

1{
2  "scripts": {
3    "dev": "concurrently -k \"vite build --watch --mode development\" \"vite build -c vite.content.config.js --watch --mode development\" \"node build/reload-server.mjs\""
4  }
5}

Execution context: package.json, run from your shell. Three processes: the two watch builds from bundling an MV3 extension with Vite, and a tiny server that tells the extension when output changed. -k makes a crash in one kill the others, so a broken build does not leave a server announcing stale output.

2. A reload server that watches the output

 1// build/reload-server.mjs
 2import { WebSocketServer } from "ws";
 3import { watch } from "node:fs";
 4
 5const wss = new WebSocketServer({ port: 35729, host: "127.0.0.1" });
 6let timer;
 7
 8watch("dist/chrome", { recursive: true }, () => {
 9  clearTimeout(timer);
10  timer = setTimeout(() => {                 // debounce: one reload per build, not per file
11    for (const c of wss.clients) c.send("reload");
12  }, 150);
13});
14console.log("reload server on ws://127.0.0.1:35729");

Execution context: Node, on your machine. Binding to 127.0.0.1 rather than all interfaces matters — a development reload channel listening on the network is something any device on the Wi-Fi can trigger. The debounce collapses a build that writes twenty files into a single reload.

3. A development-only client in the worker

 1// src/dev/reload-client.ts — imported only in development
 2export function connectReload() {
 3  let ws: WebSocket;
 4  const open = () => {
 5    ws = new WebSocket("ws://127.0.0.1:35729");
 6    ws.onmessage = async (e) => {
 7      if (e.data !== "reload") return;
 8      await reloadTabsWithContentScripts();
 9      chrome.runtime.reload();
10    };
11    ws.onclose = () => setTimeout(open, 1000);    // the server restarts with the build
12  };
13  open();
14}

Execution context: the service worker, in development builds only. The reconnect timer is acceptable here in a way it would not be in production: the open WebSocket keeps the worker alive, which is exactly what you want during development and exactly why this must never ship. Chrome treats an active WebSocket as activity for worker lifetime purposes from version 116.

4. Reload the tabs running your content scripts

1async function reloadTabsWithContentScripts() {
2  const patterns = chrome.runtime.getManifest().content_scripts?.flatMap((c) => c.matches ?? []) ?? [];
3  if (!patterns.length) return;
4  const tabs = await chrome.tabs.query({ url: patterns });
5  await Promise.all(tabs.map((t) => t.id && chrome.tabs.reload(t.id)));
6}

Execution context: the service worker, just before runtime.reload(). Doing it before the extension reload matters: afterwards the worker is a new instance and this code has already been torn down. Without this step the old content script keeps running and every call it makes throws “Extension context invalidated” — the failure described in fixing extension context invalidated after an update.

5. Guarantee it is absent from release builds

1// service-worker.ts
2if (import.meta.env.DEV) {
3  import("./dev/reload-client").then((m) => m.connectReload());
4}

Execution context: the service worker. In a production build import.meta.env.DEV is replaced with false, the branch is dead code, and the bundler removes both it and the imported module. The dynamic import is acceptable here because it provides no event listener the browser needs at dispatch — it only opens a socket.

Trust but verify, in CI:

1grep -rn "35729\|reload-client\|WebSocket(" dist/chrome/ && { echo "dev reload code in release build"; exit 1; } || echo "release clean"

Execution context: your shell, against the release build. A WebSocket to localhost in a published extension is both a review finding and a genuine security issue — anything that can reach that port on the user’s machine could reload the extension at will.

Faster still: page-level HMR and state preservation

Full extension reloads cost about a second and discard all state: the popup closes, the options page reloads, the worker restarts. For UI work in extension pages, that is still slower than it needs to be.

Two refinements help. First, reload only what changed. If the build touched only options.js, reload the options tab rather than the whole extension.

1// reload-server.mjs — send what changed
2watch("dist/chrome", { recursive: true }, (_ev, file) => {
3  const kind = file.startsWith("content/") ? "content" : file.endsWith(".html") || /popup|options|panel/.test(file) ? "page" : "worker";
4  pending.add(kind);
5  schedule();
6});
1// reload-client.ts
2if (msg === "page") {
3  const pages = await chrome.tabs.query({ url: chrome.runtime.getURL("*") });
4  await Promise.all(pages.map((t) => chrome.tabs.reload(t.id!)));
5  return;                                     // worker untouched, its state kept
6}

Execution context: Node for the server, the service worker for the client. Keeping the worker running across page edits preserves whatever state it held — in-memory caches, an open port — which is the difference between iterating on a screen and re-navigating to it every time.

Second, persist UI state you would otherwise lose. The same storage.session restore that makes the popup survive being closed — covered in why the popup closes and how to work with it — makes it survive a development reload too, for free.

What each reload scope keepsFull extension reload, page-only reload and content-script-only reload compared on worker state, extension page state, content script freshness and time taken.ScopeWorker statePage stateTimeFull runtime.reload()LostLost~1 sExtension pages onlyKeptRestored from session~200 msContent-script tabs onlyKeptKeptPage load time
Scoping the reload to what changed is what makes UI iteration feel instant.
A scoped reload after editing the options pageThe watch build rewrites options.js, the reload server classifies the change as a page change, and the worker reloads only the options tab while keeping its own state.Watch buildReload serverService workerOptions tabwrite dist/chrome/options.jsfs.watch eventws: "page"debouncedtabs.reload(optionsTab)restore fr…
The worker never restarts, so nothing it held in memory is lost.

Cross-browser variation

  • Chrome / Edge: chrome.runtime.reload() restarts the extension and reloads its pages. The WebSocket client keeps the worker alive while connected, which is fine in development.
  • Firefox: web-ext run already watches the source directory and reloads the extension automatically, including a fresh temporary install. Point it at dist/firefox and skip the custom client.
  • Safari: extensions ship inside an app, so a reload means rebuilding the app in Xcode. Iterate on Chrome and verify on Safari periodically rather than trying to hot-reload there.
  • All three: a reload re-runs onInstalled with reason: "update". Guard first-run behaviour so development reloads do not open an onboarding tab every time.

Verification

  1. Run npm run dev, load dist/chrome unpacked, then edit a string in the popup source and save. Reopen the popup within two seconds — the new string should be there.
  2. Edit the content script and confirm the tab running it reloads and logs the new build:
1console.debug("[content] build", __BUILD_ID__);

Execution context: the content script, with __BUILD_ID__ defined by the bundler from the current time in development. A stale id after a save means the tab reload step did not run.

  1. Build for release and run the CI grep; it must print “release clean”.
  2. Edit only the options page and confirm the worker did not restart — its DevTools session should stay attached.

FAQ

Is there an official hot-reload mechanism?

Not for Chrome. Firefox’s web-ext run is the closest thing to one. Everything else is a pattern like this one, which is why keeping it small and obviously development-only matters.

Why not use chrome.management to reload?

It can reload other extensions, which requires the management permission — a permission you do not want in a release manifest. chrome.runtime.reload() needs no permission and reloads only the calling extension.

My reload loops forever — why?

The watcher is probably watching a directory the build also writes to on reload, or onInstalled writes a file that triggers a rebuild. Watch only dist/ and never write into it from the extension.

Other MV3 Architecture & Extension Lifecycle Resources