Building a Chrome Extension with Manifest V3: Lessons from STACKFOLO
When we started building STACKFOLO, a new-tab dashboard for developers managing multiple side projects, the timing was unfortunate in one specific way: Chrome was in the middle of deprecating Manifest V2.
We had to build entirely on Manifest V3 from day one. No migration to worry about, but also no safety net of "just port this from MV2." Every decision had to work within MV3's constraints. Here is what we learned.
Why MV3 Changes the Mental Model
If you built Chrome extensions on MV2, you are used to background pages. A background page is a persistent JavaScript context that lives as long as the browser is open. You can cache data in memory, maintain open WebSocket connections, and count on your event listeners always being registered.
MV3 replaces background pages with service workers. Service workers are not persistent. Chrome terminates them when idle, typically after 30 seconds of inactivity. They wake up when an event fires, handle it, and go back to sleep.
This sounds like a minor architectural change. In practice, it rewires how you think about state management and background processing.
The Problem With In-Memory State
On MV2, you might write code like this:
// background.js (MV2) - this worked fine
let userProjects = [];
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_PROJECTS') {
sendResponse({ projects: userProjects }); // always available
}
});
On MV3, userProjects will be empty every time the service worker wakes up from sleep. Any in-memory state is gone.
The fix is straightforward but requires discipline: never rely on service worker memory for persistent state. Everything that needs to survive across events must go through chrome.storage or an external database.
// background.js (MV3) - correct pattern
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_PROJECTS') {
chrome.storage.local.get(['projects'], (result) => {
sendResponse({ projects: result.projects || [] });
});
return true; // keep the message channel open for async response
}
});
That return true is easy to miss and causes subtle bugs where sendResponse gets called after the listener returns, silently failing.
Firebase Integration: The Auth Challenge
STACKFOLO uses Firebase for cloud sync (Pro feature). Firebase Auth works fine in content scripts and popup pages, but the service worker context presented a specific problem.
The Firebase Web SDK assumes a persistent JavaScript environment. When you call onAuthStateChanged, it sets up a listener that fires when auth state changes. In a service worker, that listener is gone every time the worker restarts.
Our solution was to avoid relying on onAuthStateChanged in the service worker entirely. Instead, we:
-
Persist the auth token manually using
chrome.storage.localafter login - Initialize Firebase with the stored token on each service worker wake-up
- Handle token refresh explicitly rather than relying on Firebase's automatic refresh
// Simplified pattern for Firebase auth in MV3 service worker
async function getAuthenticatedUser() {
const stored = await chrome.storage.local.get(['authToken', 'tokenExpiry']);
if (!stored.authToken || Date.now() > stored.tokenExpiry) {
// Token missing or expired, user needs to re-authenticate via popup
return null;
}
// Use the stored token for Firestore requests directly
return stored.authToken;
}
The popup page handles the actual Firebase Auth UI and login flow. Once authenticated, it stores the token in chrome.storage.local where the service worker can access it. The service worker never tries to maintain its own auth state.
This pattern is more verbose than the typical Firebase setup, but it works reliably under MV3's constraints.
Content Script Communication Patterns
STACKFOLO's AI Smart Save feature needs to analyze the current page content. That means the content script (which has DOM access) needs to talk to the service worker (which handles AI API calls).
MV3 makes this a bit more complex because of stricter message passing rules. Here is the pattern we settled on:
// content-script.js
async function saveCurrentPage() {
const pageData = {
title: document.title,
url: window.location.href,
description: document.querySelector('meta[name="description"]')?.content || '',
// Extract main content for AI analysis
bodyText: document.body.innerText.slice(0, 3000)
};
const response = await chrome.runtime.sendMessage({
type: 'AI_SAVE_PAGE',
payload: pageData
});
return response;
}
// service-worker.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'AI_SAVE_PAGE') {
handleAISave(message.payload)
.then(result => sendResponse({ success: true, data: result }))
.catch(err => sendResponse({ success: false, error: err.message }));
return true; // async response
}
});
One thing MV3 is strict about: you cannot call chrome.tabs.executeScript from content scripts. All scripting API calls must go through the service worker. This bit us early on when we tried to inject helper code from a content script context.
AI Classification Architecture
The AI Smart Save feature is STACKFOLO's most complex piece. When you save a page, we want to automatically determine:
- Which project this resource belongs to
- A short purpose description (why you'd reference this)
- A quality rating
Doing this inside a Chrome extension has two constraints that do not apply to regular web apps:
1. You cannot run AI models locally (practically speaking)
Chrome extensions run in a browser context with limited compute. While the Chrome AI APIs are improving, for production use we call an external API. The service worker makes the call.
2. The service worker has a strict timeout
Chrome enforces a maximum service worker lifetime. For AI API calls that can take 2-5 seconds, this is fine. But if we wanted to do bulk processing (saving 10 resources at once), we needed to be careful about chaining async operations.
Our architecture:
Content Script
|
| (page data)
v
Service Worker
|
| (batched or single request)
v
AI API (external)
|
| (classification result)
v
Service Worker
|
| (write to Firestore)
v
chrome.storage.local (cache)
|
| (message back)
v
New Tab Page (UI update)
The new tab page is a Chrome Extension page (not a content script), so it has direct access to chrome.storage and can listen for storage changes to update the UI without going through the service worker.
// new-tab.js - listening for resource updates
chrome.storage.onChanged.addListener((changes, namespace) => {
if (namespace === 'local' && changes.recentResources) {
renderResources(changes.recentResources.newValue);
}
});
This pattern keeps the UI responsive. The service worker does the heavy lifting asynchronously, and the UI reacts to storage changes rather than waiting for a message response.
The New Tab Page Is Different From Other Extension Pages
One thing that catches people off guard: chrome.newtab is not a standard API. Your extension provides a new tab by declaring it in the manifest:
{
"chrome_url_overrides": {
"newtab": "newtab.html"
}
}
What is less obvious is that the new tab page behaves differently from popup pages in a few ways:
- It loads fresh on every new tab open (no caching of the page state)
- Chrome gives it slightly different permissions in some edge cases
- Users can only have one extension override the new tab, so if you are replacing the new tab you are asking users to commit to your tool
That last point has UX implications. We put an onboarding step early in the flow that explains the new tab replacement and gives users an easy way to disable it. Extensions that hijack the new tab without explaining themselves get bad reviews.
Permissions: Ask for Less, Get More
MV3 introduced more granular permission controls. The principle is "least privilege," and Chrome will now warn users more prominently about extensions requesting broad permissions.
We made a deliberate choice to avoid tabs permission and use activeTab instead for most features. The difference:
-
tabs: Access tab information across all open tabs at all times -
activeTab: Access the current tab only when the user explicitly invokes the extension
For AI Smart Save, the user clicks the extension icon (or uses the keyboard shortcut), which grants activeTab access at that moment. We do not need to read all open tabs passively.
This reduces the permission warning surface at install time, which measurably affects conversion from Chrome Web Store page to install.
What We Would Do Differently
Start with a clear storage schema. We iterated on our chrome.storage.local data structure several times, and each migration was painful. Treat your storage schema like a database schema. Version it from day one.
Write integration tests for the service worker lifecycle. Unit tests miss the class of bugs that only appear when the service worker restarts mid-operation. Write tests that explicitly terminate and restart the worker between operations.
Separate the extension logic from the UI logic early. We ended up with too much business logic in the new tab page initially, which made testing hard. The extension should be a thin layer. Logic belongs in the service worker or in shared modules.
Building on MV3 Today
MV3 is the present and future of Chrome extensions. The constraints are real, but they are workable once you internalize the service worker lifecycle.
The biggest mental shift: stop treating the extension like a long-running server process and start treating it like a collection of event handlers. Each handler should be stateless except for what it reads from storage.
If you are building a Chrome extension that needs persistent background processing, look into the offscreen API (MV3 adds this for use cases like audio playback that genuinely need a persistent context). For most extensions, the service worker model is sufficient once you stop fighting it.
STACKFOLO is the developer dashboard we built using these patterns. If you are managing multiple side projects and want to see how it handles AI resource classification in practice, you can try it for free.
Top comments (0)