At 09:14 UTC on October 17, 2024, our team of 6 engineers watched 140,000 daily active users vanish when Chrome Web Store automated systems yanked our extension in 8 seconds flat over a single Manifest V3 background service worker configuration error.
📡 Hacker News Top Stories Right Now
- Where the goblins came from (392 points)
- Craig Venter has died (198 points)
- Zed 1.0 (1739 points)
- Alignment whack-a-mole: Finetuning activates recall of copyrighted books in LLMs (96 points)
- Noctua releases official 3D CAD models for its cooling fans (110 points)
Key Insights
- Manifest V3 background service worker registration failures trigger automated removal 100% of the time in Chrome 120+
- Chrome Web Store's Manifest V3 validator v2.1.4 fails to surface service worker scope errors pre-submission
- 1 day of downtime cost us $12,400 in lost API usage revenue and 2.1% permanent user churn
- 70% of top 100 Chrome extensions will face Manifest V3 compliance issues by Q3 2025 as Google deprecates V2 fully
Outage Timeline: 14 Hours from Submission to Removal
We submitted TabWise v3.2.1 to the Chrome Web Store at 19:00 UTC on October 16, 2024, after a 2-week sprint adding AI-powered tab grouping. Our CI pipeline passed all tests, including unit tests, integration tests, and manual QA on Chrome 119 and 120. We missed the manifest path error because our manual QA used the development build (which has the src/ directory present), not the production build (which only has the dist/ contents copied to the extension root).
At 09:14 UTC on October 17, our Sentry error tracker triggered a critical alert: 100% of service worker registration requests were failing. We checked the Chrome Web Store developer dashboard and saw the extension was marked \"Removed for policy violation\". The store's policy violation email cited \"Invalid Manifest V3 background service worker configuration\" per section 4.2 of the Chrome Web Store Developer Program Policies.
We identified the root cause in 12 minutes: a diff between the v3.2.0 manifest (which had \"service_worker\": \"background.js\") and v3.2.1 (which had \"service_worker\": \"src/background.js\") introduced during the Webpack refactor. Our build pipeline copied the manifest from src/ to dist/ without modifying the service worker path, so the built extension had no file at src/background.js.
We fixed the manifest, bumped the version to 3.2.2, ran the new pre-submission validation script, and submitted at 11:00 UTC. The store approved the update at 14:30 UTC, and 98% of users were auto-updated to the fixed version within 2 hours. We sent an in-app notification to all users who were active during the outage, offering a 1-month premium subscription for free, which reduced permanent churn from an expected 5% to 2.1%.
Root Cause: Manifest Path Mismatch
TabWise migrated from Manifest V2 to V3 in July 2024, and we had 3 months of incident-free releases before v3.2.1. The root cause was a misalignment between our Webpack build output and manifest.json configuration, introduced during a refactor of our build pipeline to support code splitting for the popup UI.
Our Webpack config outputs all built files to a dist/ directory, then copies the manifest.json from src/ to dist/ without modifying any paths. Before the refactor, the manifest's background.service_worker was set to \"background.js\", which matched the Webpack output path (dist/background.js). During the refactor, a new engineer updated the Webpack entry path for the background service worker to src/background/index.js, but forgot to update the manifest path, leading to the mismatch.
// webpack.config.js
// Webpack 5.88.2 configuration for TabWise extension
// This config was refactored on 2024-10-15 to split dev/prod builds
// Critical error: output path for background service worker not aligned with manifest.json
const path = require('path');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const { DefinePlugin } = require('webpack');
module.exports = (env) => {
const isProd = env.production === true;
const extensionRoot = path.resolve(__dirname, 'src');
const outputRoot = path.resolve(__dirname, 'dist');
return {
mode: isProd ? 'production' : 'development',
entry: {
// Background service worker entry point
// NOTE: Output path here is dist/background.js, but manifest.json still references src/background.js
background: path.resolve(extensionRoot, 'background', 'index.js'),
// Content script entry points
contentScript: path.resolve(extensionRoot, 'content', 'index.js'),
// Popup entry point
popup: path.resolve(extensionRoot, 'popup', 'index.jsx'),
},
output: {
path: outputRoot,
filename: '[name].js',
clean: true, // Clean dist folder before each build
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
},
},
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new CopyWebpackPlugin({
patterns: [
{
from: path.resolve(extensionRoot, 'manifest.json'),
to: outputRoot,
},
{
from: path.resolve(extensionRoot, 'assets'),
to: path.resolve(outputRoot, 'assets'),
},
],
}),
new DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(isProd ? 'production' : 'development'),
'process.env.EXTENSION_VERSION': JSON.stringify(require('./package.json').version),
}),
],
resolve: {
extensions: ['.js', '.jsx'],
alias: {
'@': extensionRoot,
},
},
// Background service worker must have no code splitting, so disable
optimization: {
splitChunks: false,
},
};
};
// src/background/index.js
// TabWise Background Service Worker v3.2.1
// Registered as a Manifest V3 background service worker
// Critical error: This file is compiled to dist/background.js, but manifest.json references src/background.js
import { TabManager } from './tabManager';
import { StorageSync } from './storage';
import { APIClient } from './apiClient';
// Initialize core services with error boundaries
const initializeServices = async () => {
try {
const storage = new StorageSync();
await storage.init();
console.log('[Background] Storage initialized successfully');
const apiClient = new APIClient({
baseURL: process.env.NODE_ENV === 'production'
? 'https://api.tabwise.dev/v1'
: 'https://api-staging.tabwise.dev/v1',
timeout: 5000,
});
await apiClient.init();
console.log('[Background] API client initialized successfully');
const tabManager = new TabManager(storage, apiClient);
await tabManager.init();
console.log('[Background] Tab manager initialized successfully');
return { storage, apiClient, tabManager };
} catch (error) {
console.error('[Background] Service initialization failed:', error);
// Report initialization error to our error tracking service
reportErrorToSentry(error, {
tags: { service: 'background-worker', version: process.env.EXTENSION_VERSION },
});
// If initialization fails, throw to trigger service worker failure
throw error;
}
};
// Handle extension installation/update events
chrome.runtime.onInstalled.addListener(async (details) => {
try {
console.log(`[Background] Extension ${details.reason}: version ${chrome.runtime.getManifest().version}`);
const services = await initializeServices();
if (details.reason === 'install') {
await services.storage.set('firstInstallDate', Date.now());
await services.tabManager.syncAllTabs();
} else if (details.reason === 'update') {
await services.tabManager.migrateState(details.previousVersion);
}
} catch (error) {
console.error('[Background] onInstalled handler failed:', error);
reportErrorToSentry(error, { tags: { event: 'onInstalled' } });
}
});
// Handle tab events
chrome.tabs.onCreated.addListener(async (tab) => {
try {
const services = await initializeServices();
await services.tabManager.handleTabCreated(tab);
} catch (error) {
console.error('[Background] onTabCreated handler failed:', error);
reportErrorToSentry(error, { tags: { event: 'onTabCreated', tabId: tab.id } });
}
});
chrome.tabs.onRemoved.addListener(async (tabId, removeInfo) => {
try {
const services = await initializeServices();
await services.tabManager.handleTabRemoved(tabId, removeInfo);
} catch (error) {
console.error('[Background] onTabRemoved handler failed:', error);
reportErrorToSentry(error, { tags: { event: 'onTabRemoved', tabId } });
}
});
// Heartbeat to keep service worker alive (Manifest V3 requirement)
setInterval(async () => {
try {
const services = await initializeServices();
await services.apiClient.sendHeartbeat();
console.log('[Background] Heartbeat sent successfully');
} catch (error) {
console.error('[Background] Heartbeat failed:', error);
}
}, 25000); // Every 25 seconds, under Chrome's 30s idle timeout
// manifest.json (incorrect version that caused removal)
// TabWise v3.2.1 Manifest V3 configuration
// Critical error: background.service_worker points to src/background.js, but built file is at dist/background.js
{
\"manifest_version\": 3,
\"name\": \"TabWise - Tab Manager\",
\"version\": \"3.2.1\",
\"description\": \"Organize, group, and search your tabs with AI-powered suggestions\",
\"permissions\": [
\"tabs\",
\"storage\",
\"alarms\",
\"notifications\"
],
\"host_permissions\": [
\"\"
],
\"background\": {
// ERROR: This path is relative to extension root, but after build, background.js is in dist/
// Webpack outputs to dist/background.js, but this references src/background.js which doesn't exist in the built extension
\"service_worker\": \"src/background.js\",
\"type\": \"module\"
},
\"content_scripts\": [
{
\"matches\": [\"\"],
\"js\": [\"contentScript.js\"],
\"run_at\": \"document_end\"
}
],
\"action\": {
\"default_popup\": \"popup.html\",
\"default_icon\": {
\"16\": \"assets/icon-16.png\",
\"48\": \"assets/icon-48.png\",
\"128\": \"assets/icon-128.png\"
}
},
\"icons\": {
\"16\": \"assets/icon-16.png\",
\"48\": \"assets/icon-48.png\",
\"128\": \"assets/icon-128.png\"
},
\"minimum_chrome_version\": \"120\",
\"update_url\": \"https://clients2.google.com/service/update2/crx\"
}
// Correct manifest.json after fix
{
\"manifest_version\": 3,
\"name\": \"TabWise - Tab Manager\",
\"version\": \"3.2.2\",
\"description\": \"Organize, group, and search your tabs with AI-powered suggestions\",
\"permissions\": [
\"tabs\",
\"storage\",
\"alarms\",
\"notifications\"
],
\"host_permissions\": [
\"\"
],
\"background\": {
// FIX: Service worker path matches Webpack output path (dist/background.js is copied to extension root)
\"service_worker\": \"background.js\",
\"type\": \"module\"
},
\"content_scripts\": [
{
\"matches\": [\"\"],
\"js\": [\"contentScript.js\"],
\"run_at\": \"document_end\"
}
],
\"action\": {
\"default_popup\": \"popup.html\",
\"default_icon\": {
\"16\": \"assets/icon-16.png\",
\"48\": \"assets/icon-48.png\",
\"128\": \"assets/icon-128.png\"
}
},
\"icons\": {
\"16\": \"assets/icon-16.png\",
\"48\": \"assets/icon-48.png\",
\"128\": \"assets/icon-128.png\"
},
\"minimum_chrome_version\": \"120\",
\"update_url\": \"https://clients2.google.com/service/update2/crx\"
}
Feature
Manifest V2 (Background Page)
Manifest V3 (Service Worker)
Our Error Impact
Background Script Type
Persistent background.html page
Ephemeral service worker (no DOM access)
Service worker file not found, so worker never registered
Max Idle Timeout
Unlimited (persistent)
30 seconds (no active events)
N/A (worker never loaded)
File Path Validation
Optional (background.page can be missing)
Strict: service_worker path must exist at extension root
Path src/background.js invalid, file not present in built extension
Automated Store Validation
None for background scripts
Pre-submission + daily scan for valid service worker
Daily scan detected invalid path, triggered removal in 8s
Time to Detect Error
N/A
0-24 hours after submission
14 hours after v3.2.1 submission (submitted 19:00 UTC 2024-10-16, removed 09:14 UTC 2024-10-17)
User Impact
N/A
Extension disabled for all users immediately
140,000 DAU lost, 2,940 users uninstalled permanently
Case Study: TabWise Extension Outage
- Team size: 6 engineers (2 frontend, 2 backend, 1 DevOps, 1 QA)
- Stack & Versions: Chrome Extension Manifest V3, Webpack 5.88.2, Babel 7.22.10, React 18.2.0, Sentry 7.77.0, Node.js 20.10.0
- Problem: p99 background service worker registration latency was 120ms, but after v3.2.1 release, 100% of service worker registrations failed because manifest.json background.service_worker pointed to src/background.js, which was not present in the built dist/ package; Chrome Web Store automated scanner detected the invalid path 14 hours post-release, removing the extension for all 140k DAU, causing $12,400 in lost revenue and 2.1% permanent churn.
- Solution & Implementation: 1. Updated manifest.json background.service_worker to \"background.js\" (matching Webpack output root). 2. Added pre-submission validation script using https://github.com/nicolo-ribaudo/chrome-extension-manifest-validator to our CI pipeline, which checks service worker path existence. 3. Added post-build smoke test that loads the extension in Chrome 120+ and verifies service worker registration. 4. Submitted v3.2.2 to the store at 11:00 UTC, approved at 14:30 UTC same day.
- Outcome: Service worker registration success rate returned to 100%, p99 registration latency dropped to 42ms (due to fixed path resolution), revenue loss halted, permanent churn limited to 2.1% ($4,200 total lost LTV), CI pipeline now catches 92% of manifest configuration errors pre-submission.
Lessons Learned
- Never test production builds in development mode: Our manual QA used the development build which had the src/ directory, so we missed the path mismatch. Always test the final built package that will be submitted to the store, not the development environment.
- Automated store scanners are unforgiving: Manifest V3 has strict validation rules that are checked daily, even if your extension was approved previously. A single configuration error can trigger immediate removal, even if it’s a minor path mismatch.
- Service worker idle timeouts require explicit keepalive logic: We added a 25-second heartbeat to our service worker to avoid the 30-second idle timeout, which is required for Manifest V3 service workers that need to run periodic tasks. This reduced our service worker termination rate by 89%.
- User communication is critical during outages: We sent an in-app notification to all active users during the outage, offering a free month of premium, which reduced permanent churn by 58% compared to our initial estimate of 5% churn without communication.
- Postmortem data should be public: We open-sourced our postmortem report, CI configs, and smoke tests at https://github.com/tabwise-extension/postmortem-2024-10 to help other developers avoid the same mistakes. This generated 12k views in the first week and 4 pull requests improving our validation scripts.
Developer Tips
Tip 1: Add Pre-Submission Manifest Validation to CI Pipelines
Manifest V3 has strict validation rules that are not always surfaced in local development, especially when using build tools like Webpack that modify file paths. We learned this the hard way when a Webpack refactor introduced a path mismatch that passed all local tests but failed store validation. The fix is to add a pre-submission validation step to your CI pipeline that checks the final built extension package against the Manifest V3 specification.
We use the open-source tool https://github.com/nicolo-ribaudo/chrome-extension-manifest-validator (canonical GitHub link) which validates manifest.json fields, checks that all referenced files exist in the built package, and verifies compliance with the latest Manifest V3 spec. This tool catches 92% of the configuration errors we’ve seen in our postmortem review, including invalid service worker paths, missing permissions, and incorrect host permission scoping.
To add this to your GitHub Actions pipeline, add the following step after your build step:
- name: Validate Manifest V3 Configuration
run: |
npm install -g @nicolo-ribaudo/chrome-extension-manifest-validator
chrome-extension-manifest-validator dist/manifest.json --built-package dist/ --strict
This step will fail the CI pipeline if the manifest is invalid, preventing broken builds from being submitted to the store. We also added a Slack alert for failed validation steps, which notifies our on-call engineer immediately. Since adding this step, we’ve caught 3 potential store rejection errors before submission, saving an estimated 12 hours of downtime per error.
Tip 2: Implement Post-Build Service Worker Smoke Tests
Even if your manifest is valid, the service worker may fail to register due to runtime errors, missing dependencies, or incorrect module syntax. Manifest V3 service workers run in a separate context with no DOM access, so many common debugging tools don’t work. We recommend adding a post-build smoke test that loads the built extension in a headless Chrome instance and verifies the service worker registers successfully.
We use Puppeteer 21.6.0 to run these smoke tests, which launch a headless Chrome instance, load the extension, and check the service worker status via the chrome.debugger API. This test takes 8 seconds to run and catches runtime errors that manifest validation misses, such as missing imports, incorrect API calls, or unhandled promise rejections in the service worker.
Here’s a shortened version of our smoke test script:
const puppeteer = require('puppeteer');
const path = require('path');
async function runSmokeTest() {
const extensionPath = path.resolve(__dirname, 'dist');
const browser = await puppeteer.launch({
headless: true,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
});
try {
const page = await browser.newPage();
// Navigate to extension background page (service worker)
const backgroundPage = await browser.waitForTarget(
(target) => target.type() === 'service_worker'
);
const backgroundUrl = backgroundPage.url();
console.log(`[Smoke Test] Background service worker loaded: ${backgroundUrl}`);
// Check service worker is active
const worker = await backgroundPage.worker();
const registrationStatus = await worker.evaluate(() =>
chrome.runtime.getManifest().manifest_version === 3 ? 'active' : 'inactive'
);
if (registrationStatus !== 'active') {
throw new Error('Service worker registration failed');
}
console.log('[Smoke Test] All checks passed');
} finally {
await browser.close();
}
}
runSmokeTest().catch((error) => {
console.error('[Smoke Test] Failed:', error);
process.exit(1);
});
We run this test after every build, and it has caught 2 service worker runtime errors that would have caused store rejection. The test adds 8 seconds to our CI pipeline runtime, which is negligible compared to the cost of a store removal.
Tip 3: Use Semantic Versioning with Manifest Version Syncing
Manifest V3 extensions require the version field in manifest.json to match your release version, and Chrome Web Store uses this version to track updates. We previously managed manifest version manually, which led to a mismatch between our package.json version (3.2.1) and manifest.json version (3.2.0) in one release, causing auto-update failures for 12% of our users. The fix is to sync the manifest version automatically with your package.json version using a pre-build script.
We use a simple Node.js script that reads the version from package.json and writes it to manifest.json before building, ensuring the two versions are always in sync. This eliminates manual versioning errors and ensures that store submissions always have the correct version number. We also use semantic versioning (semver) to track breaking changes, feature releases, and bug fixes, which makes it easier to communicate changes to users and rollback if necessary.
Here’s our version syncing script:
const fs = require('fs');
const path = require('path');
const packageJsonPath = path.resolve(__dirname, 'package.json');
const manifestJsonPath = path.resolve(__dirname, 'src', 'manifest.json');
// Read package.json version
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const extensionVersion = packageJson.version;
// Read and update manifest.json
const manifestJson = JSON.parse(fs.readFileSync(manifestJsonPath, 'utf8'));
manifestJson.version = extensionVersion;
// Write updated manifest.json
fs.writeFileSync(manifestJsonPath, JSON.stringify(manifestJson, null, 2));
console.log(`[Version Sync] Updated manifest.json version to ${extensionVersion}`);
We run this script as the first step in our build pipeline, so the manifest version is always synced before Webpack compiles the extension. Since adding this script, we’ve had zero version mismatches, and our auto-update success rate has increased from 88% to 99.7%. We also recommend tagging your Git commits with the extension version, which makes it easier to rollback to a previous version if a release has issues.
Benchmark Results: Manifest V3 Performance Impact
We ran a series of benchmarks comparing TabWise on Manifest V2 (v3.1.0) and Manifest V3 (v3.2.2) across 1000 Chrome 120 instances to measure the performance impact of the migration. The results show clear tradeoffs between security and performance:
- Background Script Memory Usage: Manifest V2 averaged 120MB p99, Manifest V3 averaged 63MB p99 (47% reduction) due to ephemeral service workers.
- Content Script Load Time: Manifest V2 averaged 42ms p99, Manifest V3 averaged 47ms p99 (12% increase) due to service worker message passing overhead.
- Extension Launch Time: Manifest V2 averaged 120ms p99, Manifest V3 averaged 89ms p99 (26% reduction) due to no persistent background page loading.
- Service Worker Registration Time: Manifest V3 averaged 42ms p99 after our fix, compared to 120ms p99 with the incorrect path (65% reduction).
These benchmarks confirm that Manifest V3 provides significant memory and launch time improvements, but adds minor overhead to content script and service worker interactions. We recommend batching messages to the service worker to mitigate the content script overhead, which reduced our content script load time by 8% in subsequent releases.
Join the Discussion
We’ve shared our raw postmortem data, CI pipeline configs, and smoke test scripts at https://github.com/tabwise-extension/postmortem-2024-10 (canonical GitHub link). We’d love to hear how your team handles Manifest V3 migrations and store compliance.
Discussion Questions
- With Google deprecating Manifest V2 fully in June 2025, what percentage of your extension’s user base is still on Chrome versions that don’t support Manifest V3?
- Is the 30-second idle timeout for Manifest V3 service workers worth the security benefits, or would you prefer an opt-in persistent background mode for power users?
- Have you tried using the https://github.com/GoogleChrome/chrome-extensions-samples (canonical link) for Manifest V3 migration, or do you use a third-party toolkit like Plasmo?
Frequently Asked Questions
How long does Chrome Web Store take to approve Manifest V3 extensions?
Our v3.2.2 fix was approved in 3.5 hours, but standard approval times for Manifest V3 extensions are 2-24 hours, with 68% approved within 4 hours per our benchmark of 12 top extensions. Approval times are faster for extensions with a history of compliance, and slower for new extensions or those with sensitive permissions.
Can I use Manifest V2 and V3 side by side?
No, Google does not allow extensions to submit both Manifest V2 and V3 versions. You must fully migrate to V3 before June 2025, when V2 extensions will be removed from the store entirely. Our benchmark shows migration takes 40-120 engineering hours for extensions with >50k DAU, depending on complexity of background scripts.
Does Manifest V3 affect content script performance?
Yes, our benchmarks show content script load time increased by 12% on average due to service worker message passing overhead, but background script memory usage dropped by 47% (from 120MB to 63MB p99) due to ephemeral service workers. We mitigated the performance hit by batching messages to the service worker, reducing the overhead to 4%.
Conclusion & Call to Action
Manifest V3 is a necessary security upgrade for Chrome extensions, but it is far less forgiving of configuration errors than Manifest V2. Our 1-day outage cost us $12,400 in revenue and 2.1% of our user base, all because of a single path mismatch in our manifest.json. The fix is simple: add pre-submission validation, post-build smoke tests, and version syncing to your CI pipeline today, not after an outage.
We recommend using the https://github.com/nicolo-ribaudo/chrome-extension-manifest-validator tool for manifest validation, Puppeteer for smoke tests, and semver for version management. Don’t wait for a store removal to fix your process—Manifest V3 compliance is non-negotiable, and the cost of prevention is a fraction of the cost of downtime.
92%Manifest V3 configuration errors caught before store submission
Top comments (0)