I spent a weekend debugging a Chrome extension dev environment that stopped working after a Chrome update. No error messages. The extension loaded — I could open its options page — but content scripts never ran, service workers never started, and the UI stayed unchanged.
This post is about three separate failure modes I hit, why each happens, and the minimal fix for each.
The Setup
The project uses WXT (a Chrome extension framework built on Vite) with a custom dev.sh script that:
- Starts the WXT dev server for hot reload
- Launches a dedicated Chrome instance with the extension loaded
- Exposes Chrome's remote debugging port for MCP/DevTools access
A clean dev-build → chrome-start → edit-reload loop. Or so it was.
Problem 1: --load-extension No Longer Starts Service Workers in Chrome 126+
What broke
Chrome has a flag --load-extension=/path/to/ext that loads an unpacked extension at startup. Before Chrome 126, this worked well for local development. After Chrome 126, the extension appears to load — chrome-extension://ID/options.html is accessible — but:
- The background service worker never appears in
Target.getTargetsCDP results - Content scripts declared in
manifest.content_scriptsare not injected - The extension is not written to the Chrome profile's
Secure Preferences
The last point is the key. With --load-extension, Chrome 126+ treats the extension as ephemeral. It's accessible as a filesystem resource but not actually "installed" in the profile, so Chrome's normal extension machinery (service worker lifecycle, content script injection) doesn't activate.
Why web-ext switched to Extensions.loadUnpacked
The web-ext project documented this in their issue tracker and switched away from --load-extension for Chrome 126+. Their new approach:
- Start Chrome with
--remote-debugging-pipeand--enable-unsafe-extension-debugging - Call the
Extensions.loadUnpackedCDP command via the pipe
Extensions.loadUnpacked writes to Secure Preferences just like clicking "Load unpacked" in chrome://extensions/. Once written, the extension is a real installed extension.
Critical caveat: Extensions.loadUnpacked is only available via pipe-based CDP (--remote-debugging-pipe), not via WebSocket-based CDP (--remote-debugging-port). Connecting via port returns "Method not available." even with --enable-unsafe-extension-debugging.
The simpler fix
After trying the pipe approach, I discovered something: if you already have the extension registered in Secure Preferences from a previous pipe-based install, you can start Chrome normally (no --load-extension) with --enable-unsafe-extension-debugging and it loads from the profile automatically.
But there's an even simpler path: --load-extension + --enable-unsafe-extension-debugging together. Testing showed that when --enable-unsafe-extension-debugging is present, Chrome treats --load-extension extensions as real installs and injects content scripts:
"${CHROME_BINARY}" \
--user-data-dir="${USER_DATA_DIR}" \
--remote-debugging-port=9222 \
--load-extension="${EXTENSION_DIR}" \
--enable-unsafe-extension-debugging
That's the entire fix for service worker / content script injection.
Problem 2: WXT Dev Server Exits Immediately When Backgrounded
What broke
The dev script ran WXT in the background:
npm run dev &
WXT_PID=$!
# ... wait for build, start Chrome ...
wait "$WXT_PID"
WXT would build the extension, print "Load manually", and exit. The wait returned. The dev server was gone.
Why
WXT uses Node.js readline for its interactive keyboard shortcuts:
rl ??= readline.createInterface({
input: process.stdin,
// ...
})
When npm run dev & backgrounds the process, process.stdin is connected to /dev/null. readline immediately gets EOF, emits close, and WXT exits.
This only manifests when WXT is backgrounded — running it in the foreground is fine. But backgrounding is necessary because you need to start Chrome after the build completes.
The fix
Feed a non-closing stream to the process's stdin:
npm run dev < <(tail -f /dev/null) &
tail -f /dev/null follows an empty file indefinitely, never sending data and never closing. WXT's stdin stays open. readline never gets EOF. WXT keeps running.
Problem 3: web-ext Injects Flags That Break Google Login
What broke
WXT's runner (web-ext) was starting Chrome correctly — but Google account login was consistently logged out on every restart.
Why
web-ext uses chrome-launcher for Chrome startup. chrome-launcher's defaultFlags() includes:
'--disable-sync',
'--use-mock-keychain',
--use-mock-keychain is the destructive one. On macOS, Chrome encrypts cookies using the system keychain. --use-mock-keychain substitutes a fake keychain with a different encryption key. Cookies encrypted with the real keychain cannot be decrypted with the mock one and vice versa.
Once Chrome writes cookies with the mock keychain, subsequent Chrome starts (without the flag) cannot read them. Login state is destroyed.
web-ext excludes --disable-extensions, --mute-audio, and --disable-component-update from chrome-launcher's defaults — but not --disable-sync or --use-mock-keychain.
The fix
Disable WXT's web-ext runner entirely and launch Chrome manually:
// wxt.config.ts
export default defineConfig({
webExt: {
disabled: true,
},
})
Then start Chrome from dev.sh with exactly the flags you need and nothing else.
Bonus: WXT Dev Mode Removes content_scripts from the Manifest
This one isn't a Chrome regression — it's WXT's intentional behavior that became a problem once service workers stopped starting.
In dev mode, WXT strips content_scripts from manifest.json and relies on the background service worker to register them dynamically via chrome.scripting.registerContentScripts(). The service worker connects to WXT's dev server and WXT sends reload commands.
When the service worker doesn't start (Problem 1), this entire chain breaks. Content scripts are never registered.
Fix: use WXT's build:manifestGenerated hook to add content_scripts back to the dev manifest:
hooks: {
'build:manifestGenerated': (wxt, manifest) => {
// Also strip the localhost CSP entry WXT adds for HMR — Chrome MV3
// rejects http:// origins in extension_pages CSP.
const csp = manifest.content_security_policy as Record<string, string> | undefined;
if (csp?.extension_pages) {
csp.extension_pages = csp.extension_pages
.replace(/\s*http:\/\/localhost:[0-9]+/g, '')
.trim();
}
if (wxt.config.command === 'serve' && !manifest.content_scripts?.length) {
(manifest as Record<string, unknown>).content_scripts = [{
matches: ['https://your-target-site.com/*'],
run_at: 'document_end',
js: ['content-scripts/content.js'],
}];
}
},
},
This also strips http://localhost:3000 from the extension_pages CSP. Chrome MV3 forbids HTTP origins in that directive; WXT adds it for Vite HMR, but it may silently break extension loading.
The Full Picture
Three independent changes colliding:
| Problem | Root Cause | Fix |
|---|---|---|
| Service worker / content scripts not running | Chrome 126+ changed --load-extension to not register extensions in profile |
Add --enable-unsafe-extension-debugging
|
| WXT dev server exits immediately |
readline on backgrounded stdin gets EOF |
npm run dev < <(tail -f /dev/null) & |
| Google login lost on restart | web-ext injects --use-mock-keychain
|
Disable WXT runner, launch Chrome manually |
| Content scripts not injected in dev | WXT removes content_scripts from dev manifest |
Restore via build:manifestGenerated hook |
None of these produce meaningful error messages. The extension "loads" in the sense that its static pages are accessible. Everything else silently fails. The debugging path was: CDP Target.getTargets to check for service workers, Secure Preferences inspection to check if the extension was actually installed, and process-level stdin inspection to find the WXT exit cause.
The startup order and flags are what matter:
- Start the dev server in the background, feeding stdin from
tail -f /dev/nullso it stays alive - Wait for the build to finish
- Wait for the dev server to initialize before launching Chrome (so the service worker can connect)
- Launch Chrome with both
--load-extensionand--enable-unsafe-extension-debugging
npm run dev < <(tail -f /dev/null) &
WXT_PID=$!
# wait for build → wait for dev server → launch Chrome
"${CHROME_BINARY}" \
--user-data-dir="${USER_DATA_DIR}" \
--remote-debugging-port="${DEBUG_PORT}" \
--load-extension="${EXTENSION_DIR}" \
--enable-unsafe-extension-debugging &
wait "$WXT_PID"
Top comments (0)