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.
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.
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.
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 runalready watches the source directory and reloads the extension automatically, including a fresh temporary install. Point it atdist/firefoxand 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
onInstalledwithreason: "update". Guard first-run behaviour so development reloads do not open an onboarding tab every time.
Verification
- Run
npm run dev, loaddist/chromeunpacked, then edit a string in the popup source and save. Reopen the popup within two seconds — the new string should be there. - 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.
- Build for release and run the CI grep; it must print “release clean”.
- 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.
Related
- Bundling an MV3 extension with Vite — the watch builds this sits on.
- Testing extensions in Firefox with web-ext — Firefox’s built-in reload loop.
- Fixing extension context invalidated after an update — why open tabs must reload too.
- Build tooling and bundlers — the parent guide.