Design a browser extension
A real product extension for Chrome, Edge, Firefox, and Safari built on Manifest V3, like a highlighter, a password manager, or a writing assistant. The hard parts are a background service worker that the browser kills when idle, content scripts that run on every page you don't control, messaging between short-lived contexts, permissions that users have to trust, UI injected safely into any website, storage and auth across contexts, and shipping through store review to several browsers.
The question
We run Marginal, a web app where people save highlights and notes from articles they read. We want a browser extension: select text on any web page, highlight it, add a note, and see your highlights again the next time you visit that page. A side panel lists the notes for the current page, and the toolbar button saves the whole page to your library.
Design the frontend of this extension. It must work in Chrome, Edge, and Firefox, with Safari later. It runs on every kind of website, from plain blogs to heavy apps like Gmail, and it must never break or slow down those pages. Users must trust it enough to install it.
Real-life examples: Notion Web Clipper and Glasp highlighter, 1Password and Bitwarden password managers, Grammarly writing assistant, Honey coupon finder, Google Translate and DeepL extensions, uBlock Origin Lite content blocker
Requirements exploration
An extension looks like a small app, but it isn't one app. It is several small programs that the browser starts and stops on its own schedule, glued together by messages. Some of them run inside web pages we don't own. So the first questions are less about features and more about where our code must run, and how much of the user's browser we are asking for.
Our product is a highlighter called Marginal, a lot like Glasp, Liner, or the Notion Web Clipper. We use it as the running example because it touches every hard part: it has to run on every page (to show old highlights), it injects UI into those pages, it talks to our servers, and it syncs with a web app. Where a password manager, Grammarly, or a coupon finder would decide differently, we say so.
Questions to ask first
- Does it need to run on every page, or only when the user asks? A clipper that saves the page when you click the toolbar button only needs the current tab, on demand. A highlighter that repaints old highlights the moment the page loads needs access to every site. This one answer decides the permission warning users see at install.
- Does it change the page, or only read it? Reading the selected text is light. Drawing highlights, a floating toolbar, and a note editor inside someone else's page means we must isolate our styles and never break their layout.
- Does it handle secrets? A password manager fills passwords into pages that may be hostile. That pushes all secret-handling UI out of the page and into extension-owned frames.
- Does it need to be signed in? Marginal saves to the user's account, so yes. How the extension gets and keeps credentials is its own chapter.
- Which browsers, and when? Chrome and Edge share the Chromium engine and the same manifest. Firefox supports Manifest V3 with differences. Safari needs the extension wrapped in a native app. Shipping all four on day one costs real time.
- How many users and how much data? We assume 400,000 weekly users, each visiting about 80 pages a day and owning about 2,000 highlights. That is 32 million page loads a day where our code wakes up, and nearly all of them have no highlights at all.
Functional requirements
- Select text on any page, then highlight it in one of four colors from a small floating toolbar, or with a keyboard shortcut.
- Add a note to a highlight. Notes and highlights save to the user's account and show up in the web app.
- When the user revisits a page, their old highlights reappear in the right places, even if the page changed a little.
- A side panel lists the highlights and notes for the current page and lets the user jump to each one.
- Clicking the toolbar button opens a popup that saves the whole page to the library and shows the sign-in state.
- An options page for settings: default color, sites where Marginal is turned off, shortcuts.
Non-functional requirements
| Requirement | Target | Why |
|---|---|---|
| Page cost | Under 10 KB of script on pages with no highlights; under 5 ms of main-thread work after load | Our code runs on 32 million page loads a day. Users blame the extension when pages feel slow. |
| Never break the page | Zero layout shift, no global CSS, no patched page globals | One broken checkout page earns a one-star review and an uninstall. |
| Fast surfaces | Popup and side panel show content in under 150 ms | The popup is opened, glanced at, and closed. A spinner there feels broken. |
| Survive restarts | No user action is lost when the browser kills the background worker | The browser terminates our background code after about 30 seconds idle. That's normal, not an error. |
| Least privilege | Ask for the narrowest permissions that still deliver the feature | Broad permissions scare users at install and slow down store review. |
| Security | Pages can never read the user's token or other sites' highlights | Our content script lives next to code from millions of unknown sites. |
| Cross-browser | One codebase for Chrome, Edge, Firefox; Safari through a wrapper app | Four separate codebases would never stay in sync. |
Out of scope
- The Marginal web app and the server-side storage of highlights. We treat them as an existing REST API.
- Highlighting inside PDFs and images. The built-in PDF viewer doesn't run content scripts, so it needs its own viewer.
- Mobile. Chrome on Android phones doesn't support extensions. Firefox for Android and Safari on iOS do, but touch selection and small screens need their own design, so we design for desktop first.
Check your understanding · Exercise 1
Which answers from the interviewer would change the architecture of the extension, not just its feature list? Select all that apply.
What makes an extension hard
Most of us build web apps where one page owns its JavaScript, its DOM, and its lifetime. An extension breaks all three assumptions at once. Six properties drive every decision in the rest of this guide.
- It is split into contexts. The background service worker, the content script in each tab, the popup, the side panel, and the options page are separate JavaScript worlds with separate memory. They can't call each other's functions. They can only send messages and share storage.
- The background is ephemeral. In Manifest V3 the background is a service worker. The browser starts it for an event and terminates it after about 30 seconds without events. Any variable you set in it can vanish between two clicks.
- The UI surfaces are short-lived too. The popup is destroyed the moment it loses focus. The side panel closes when the user closes it. Only storage and the server outlive them.
- We are a guest in hostile pages. Content scripts run inside pages with their own CSS, their own globals, their own Content Security Policy, and sometimes scripts that actively look for extensions. The page can also lie to us.
- Trust is the product. An extension that can read every page can read bank statements and email. Users see that warning at install, store reviewers look for it, and a single abuse story can end the product.
- We ship through gatekeepers. Every release passes store review, which usually takes a few days, and then rolls out to users on the browser's schedule, not ours. Old and new versions run side by side, so data written by version 3 must still make sense to version 4.
Put together, this is closer to a small distributed system than to a web app. Each context is a node that can disappear at any time. Messages are the network. chrome.storage and the server are the durable state.
Most bugs in real extensions come from forgetting one of these: state kept in a service worker variable, a message sent to a popup that already closed, or a content script that assumed it was alone on the page.
Think first
A teammate stores the signed-in user in a let currentUser variable at the top of the background script. It works on their machine all day. Why does it fail for real users, and why didn't the teammate see it?
With DevTools open on the service worker, Chrome keeps the worker alive, so the variable never resets. Real users don't have DevTools open. After about 30 seconds idle, the worker is terminated, and the next event starts a fresh worker where currentUser is undefined again.
The fix is to treat storage as the source of truth: write the user to chrome.storage.session or chrome.storage.local, and read it back at the start of each event handler. In-memory variables are only a cache.
The pieces of a Manifest V3 extension
Before any decision, we need the parts list. Every extension starts from manifest.json, a file that tells the browser which pieces exist, when to start them, and what they may touch.
Manifest V3 (MV3) is the current version of that format. Chrome began disabling Manifest V2 extensions in late 2024 and, with Chrome 138 in July 2025, turned them off for every user. Firefox, Edge, and Safari all support MV3 too, so a new extension starts there.
{
"manifest_version": 3,
"name": "Marginal Highlighter",
"version": "4.2.0",
"background": { "service_worker": "sw.js", "type": "module" },
"action": { "default_popup": "popup.html" },
"side_panel": { "default_path": "panel.html" },
"options_page": "options.html",
"permissions": ["storage", "alarms", "sidePanel", "contextMenus", "activeTab", "scripting"],
"host_permissions": ["https://api.marginal.app/*"],
"optional_host_permissions": ["https://*/*"],
"web_accessible_resources": [{
"resources": ["note-editor.html"],
"matches": ["https://*/*"],
"use_dynamic_url": true
}],
"commands": { "highlight": { "suggested_key": { "default": "Alt+H" }, "description": "Highlight selection" } }
}
The contexts, one by one
| Piece | Where it runs | Lives for | Can use | Marginal uses it for |
|---|---|---|---|---|
| Background service worker | Its own process, no DOM | Seconds: started per event, stopped after about 30 s idle | Almost every chrome.* API, fetch, IndexedDB; no window, no localStorage | API calls, auth, sync queue, context menu, shortcuts |
| Content script | Inside each web page, in an isolated world | As long as the page | The page DOM; only a few chrome.* APIs (runtime, storage, i18n) | Reading the selection, drawing highlights, the floating toolbar |
| Popup | An extension page shown from the toolbar button | Until it loses focus | Full DOM and chrome.* APIs | Save the page, show sign-in state |
| Side panel | An extension page docked beside the tab (Chrome 114+) | Until the user closes it | Full DOM and chrome.* APIs | List highlights and notes for the current page |
| Options page | An extension page in a tab or dialog | While open | Full DOM and chrome.* APIs | Settings |
| Offscreen document | A hidden extension page (Chrome 109+) | Until the worker closes it | DOM APIs, but only chrome.runtime messaging | Parsing HTML with DOMParser, clipboard |
| Web-accessible resources | Extension files a web page may load | Whatever loads them | Same as any extension page when framed | The note editor iframe |
The service worker is the only piece that exists without any UI open, so it is the hub: the one place that holds the auth token, talks to our API, and receives browser events like a keyboard shortcut or a context menu click. But it has no DOM.
When the worker needs a DOM, for example to turn saved HTML into text with DOMParser, it creates an offscreen document. That is a hidden extension page created with chrome.offscreen.createDocument(), which takes a reason from a fixed list (such as DOM_PARSER or CLIPBOARD) and a written justification. Chrome allows one offscreen document at a time per extension.
A content script is our JavaScript inside someone else's page. It runs in an isolated world: it sees and edits the same DOM as the page, but it has its own JavaScript globals. If the page sets window.fetch = evilFetch or defines window.app, our content script doesn't see either, and the page can't see our variables.
That is a strong guarantee for JavaScript, and no guarantee at all for the DOM and CSS, which are shared. The injected UI chapter deals with that. Chrome and Firefox also accept world: "MAIN", which runs a script in the page's own world without isolation. It exists for the rare script that must touch page globals, and the page can then see and tamper with it, so Marginal never uses it.
Content scripts get into a page two ways. Declared scripts are listed in the manifest with matches patterns, and the browser injects them into every matching page at document_start, document_end, or document_idle (the default, after the DOM is ready). Programmatic scripts are injected on demand with chrome.scripting.executeScript({ target: { tabId }, files: [...] }) from the service worker, for example when the user clicks the toolbar button. Declared needs permanent host permission; programmatic can work with the narrower activeTab permission.
The popup and side panel are ordinary HTML pages from our own extension origin, chrome-extension:// plus our id. They can use React and the full chrome.* API. The popup is fragile: clicking anywhere else destroys it, including its in-flight fetch calls and component state.
The side panel (the chrome.sidePanel API, Chrome 114 and later) stays open as the user switches tabs. That makes it the right home for a list the user reads while scrolling the article. Firefox has a similar sidebar, declared with the sidebar_action manifest key.
Web-accessible resources are extension files that web pages are allowed to load, listed under web_accessible_resources with the sites that may load them. By default no page can load any of our files. We expose exactly one: the note editor page, so the content script can show it in an iframe.
Every exposed file is also a way for a site to detect that Marginal is installed, so the list stays short. Setting use_dynamic_url makes Chrome serve the file from an id that changes every browser session, so a site can't probe a fixed URL.
Check your understanding · Exercise 2
The page Marginal runs on defines a global window.marginal = { hacked: true } and overrides Array.prototype.map. Which statements are true about our content script? Select all that apply.
Living with a background that dies
Marginal's background has three jobs that sound long-running: hold the signed-in session, push saved highlights to the server (retrying when offline), and refresh the token before it expires. In Manifest V2 we would have written a persistent background page with a setInterval and a few global variables. In MV3 that design breaks in three ways.
- Globals vanish. Chrome terminates the worker after 30 seconds without events or extension API calls. The next event runs the script from the top in a fresh worker.
- Timers never fire. A
setTimeoutfor 10 minutes dies with the worker after about 30 seconds. The token refresh silently never happens. - Late listeners miss events. If the worker wakes for a shortcut, but our
chrome.commands.onCommand.addListenerruns only after anawait, the event has already been delivered to nobody.
Here is a concrete failure. A user highlights a paragraph on a train with no signal. We put the highlight in an in-memory retry array and plan to retry every 60 seconds.
Forty seconds later the worker is gone, with the array inside it. The user sees the highlight on the page (the content script drew it), but it never reaches their account. Nobody gets an error.
Decision How should the background keep state and schedule work when it can be killed at any time?
What we pick: Stateless handlers with durable state and alarms. It is the only option that stays correct when the worker dies for reasons we don't control (an update, a crash, a browser restart), and it works the same in Firefox's event pages. In-memory variables become a cache that we are allowed to lose.
The three rules for the worker
- Register every listener synchronously at the top level of the script. When an event wakes the worker, the browser runs the script, then delivers the event to the listeners that exist. A listener added after an
awaitis too late. - Keep state in storage, not in variables. Session-only secrets such as the access token go in
chrome.storage.session, which lives in memory and survives worker restarts but is cleared when the browser closes. Things that must survive a restart go inchrome.storage.localor IndexedDB. - Schedule with alarms, not timers.
chrome.alarms.create("flush-outbox", { periodInMinutes: 1 })survives worker termination. Chrome's docs warn that alarms may be cleared when the browser restarts, so the worker checks for its alarm every time it starts. AsetTimeoutof a few seconds is fine for work inside one event, never for work across events.
import { outbox } from "./outbox"; // IndexedDB-backed queue
import { getSession, authHeaders } from "./session"; // reads chrome.storage.session
// Runs on every worker start. Alarms usually persist, but may be cleared on browser restart.
chrome.alarms.get("flush-outbox").then((alarm) => {
if (!alarm) chrome.alarms.create("flush-outbox", { periodInMinutes: 1 });
});
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === "flush-outbox") await flushOutbox();
});
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
handle(msg, sender).then(sendResponse, (e) => sendResponse({ ok: false, error: String(e) }));
return true; // keep the channel open for the async reply
});
async function flushOutbox() {
const session = await getSession();
if (!session) return; // signed out: leave items queued
for (const item of await outbox.peek(50)) {
const res = await fetch(item.url, { method: item.method, body: item.body,
headers: authHeaders(session) });
if (res.ok) await outbox.remove(item.id); else break;
}
}
Back to the train. With this design, the content script draws the highlight and sends highlight/create to the worker. The worker writes it to the IndexedDB outbox first, then tries fetch. The fetch fails, the item stays in the outbox, and the worker goes to sleep.
A minute later the alarm wakes a fresh worker, which reads the outbox and retries. No variable had to survive. This is the same outbox idea as the offline-sync-engine guide, shrunk to one queue.
Two details catch people. First, since Chrome 110 the 30-second timer restarts on every event and every extension API call, and a single event may run for up to 5 minutes. But a fetch whose response takes more than 30 seconds to start arriving gets the worker terminated, so long uploads are split into small requests.
Second, Chrome keeps the worker alive while its DevTools window is open. Always test lifecycle bugs with DevTools closed, or stop the worker by hand from chrome://serviceworker-internals.
Start with Sign in, then try the other events. Press Wait 30 s to let Chrome stop the worker.
Check your understanding · Exercise 3
Which of these background designs survive the worker being terminated between two events? Select all that apply.
Messaging between contexts
The contexts share no memory, so every feature is a conversation. Saving a highlight goes content script to worker to server and back. Opening the side panel means the panel asks the worker for the current page's highlights, and the content script must scroll when the user clicks one. We need to choose the channels, and then a protocol that stays correct when one end disappears mid-sentence.
The channels the browser gives us
| Channel | From, to | Shape | Good for |
|---|---|---|---|
chrome.runtime.sendMessage | Any extension context to the worker and extension pages | One request, optional one reply | Commands: create highlight, get session |
chrome.tabs.sendMessage(tabId, msg, { frameId }) | Worker or extension page to the content script in one tab (and frame) | One request, optional one reply | Telling a page to scroll to a highlight |
chrome.runtime.connect / chrome.tabs.connect | Between any two contexts | A long-lived Port with postMessage, onMessage, onDisconnect | Streams, such as progress while the popup saves a long page |
chrome.storage.onChanged | Every context that listens | Broadcast of key changes | Settings, sign-in state, anything many views show |
window.postMessage | Content script and the page's own JavaScript, or our iframe | Untyped, anyone on the page can send | Talking to the Marginal web app's page |
externally_connectable | Listed websites to the worker directly | chrome.runtime.sendMessage(extensionId, msg) from the page | Our web app handing the extension a login (Chromium and Safari; not Firefox) |
Decision How should the side panel stay in sync with highlights created in the page?
What we pick: Commands go by sendMessage (request and reply), and shared state goes through storage with onChanged as the bus. Ports are kept for real streams, such as progress while the popup saves a long page. This gives every view a durable source of truth, so nothing depends on the worker being alive at the moment the change happens.
A protocol, not ad hoc objects
Ad hoc messages like { action: "save", data } grow into a mess of string checks. We define a typed union of every message, one handler per type, and a validator at the edge. In Chrome, messages are serialized as JSON, so Date, Map, and undefined values don't survive the trip; Firefox uses structured clone, which keeps more types. Sticking to plain JSON avoids that difference.
export type Msg =
| { type: "highlight/create"; url: string; anchor: Anchor; color: Color; text: string }
| { type: "highlight/delete"; id: string }
| { type: "highlights/list"; url: string }
| { type: "session/get" };
const handlers: { [K in Msg["type"]]: (m: Extract<Msg, { type: K }>, s: chrome.runtime.MessageSender) => Promise<unknown> } = {
"highlight/create": createHighlight,
"highlight/delete": deleteHighlight,
"highlights/list": listHighlights,
"session/get": getSessionForUi,
};
export async function handle(raw: unknown, sender: chrome.runtime.MessageSender) {
if (sender.id !== chrome.runtime.id) throw new Error("foreign sender");
const msg = parseMsg(raw); // zod or a hand-written check; throws on bad shape
if (msg.type === "session/get" && sender.tab) throw new Error("not for content scripts");
return handlers[msg.type](msg as never, sender);
}
Note the sender checks. sender.tab is set when the message came from inside a tab, which covers content scripts and our note editor iframe, and sender.url tells them apart (a web URL or a chrome-extension:// one). A content script is our code, but it lives in hostile territory: a bug in it, or a page exploiting it, could send any message. So the worker treats content-script messages as less trusted than popup or panel messages, and never hands a content script the raw access token.
Talking to a web page's own JavaScript
Sometimes the extension must talk to the page's JavaScript, for example our own web app at app.marginal.app saying 'the user just signed in'. Our content script can't call the page's functions, but both share window.postMessage. The trouble is that every script on the page, including ads, can call postMessage too, and can pretend to be our app.
So the content script checks event.source === window, checks event.origin against an exact allowlist, and checks the message shape. The embeddable-widget guide walks through this origin-checked handshake in depth, so we only list the checks here.
In Chromium browsers and Safari there is a more direct path: list our site under externally_connectable.matches in the manifest, and pages on that origin can call chrome.runtime.sendMessage(extensionId, msg) (browser.runtime.sendMessage in Safari) straight to the worker. The browser sets sender.origin, so the page can't forge it. Firefox doesn't support this for web pages, so we keep the content-script path as the fallback.
Check your understanding · Exercise 4
The worker receives { type: "highlights/list", url: "https://bank.example/statement" } from a content script running on https://news.example. What should it do? Select all that apply.
Permissions and trust
The feature everyone wants first is 'old highlights reappear when I revisit a page'. The obvious way to build it is one manifest entry: a content script that matches every https:// page. It works on the first try. Then the install dialog shows this line: Read and change all your data on all websites.
That sentence is accurate. With it, our content script could read the user's email, their bank balance, and every password field they type in. Users know it: many stop at that dialog, and security teams at large companies block extensions that ask for it.
Store reviewers also look harder at broad host permissions, which can mean a slower review. And if our extension or our developer account is ever compromised, the attacker inherits that power on every machine. So permissions are a product decision, not a config detail.
The kinds of access
- API permissions like
storage,alarms,sidePanel, andcontextMenusunlock browser APIs. Most show no install warning. A few do, such astabs(read your browsing history) orhistory. - Host permissions (
host_permissions) grant access to sites by match pattern: injecting scripts, reading tab URLs, and making cross-origin requests from the worker without CORS limits. They are granted at install, with a warning that names the sites. activeTabgrants temporary access to the current tab only, and only after a user gesture aimed at the extension: clicking the toolbar button, choosing our context menu item, or pressing our keyboard shortcut. The access lasts while the user stays on that site and ends when the tab navigates to another origin or closes. It shows no install warning at all.- Optional permissions (
optional_permissionsandoptional_host_permissions) are declared in the manifest but requested at runtime withchrome.permissions.request(), which must be called from a user gesture. The user sees a prompt in context, when the reason is obvious, and can later revoke it.
One more fact changes the design: in Chrome and Edge, users can restrict any extension's site access to 'on click' or 'on specific sites' from the puzzle-piece menu, even if it was granted at install. Firefox lets users grant or revoke host permissions one by one. So 'we have all-sites access' is never a safe assumption. The extension must check with chrome.permissions.contains() and degrade gracefully.
Decision How should Marginal get access to the pages users read?
What we pick: activeTab first with an optional upgrade. The install is quiet, the core action (highlight what I selected) works everywhere through the user's click or shortcut, and auto-repaint is an opt-in feature that explains its own cost. A password manager makes the same choice differently: autofill on every login form is its core value, so it asks for all sites at install and earns that trust with audits and a strong reputation.
async function enableAutoRepaint(): Promise<boolean> {
const granted = await chrome.permissions.request({ origins: ["https://*/*"] });
if (!granted) return false;
await chrome.scripting.registerContentScripts([{
id: "marginal-boot",
matches: ["https://*/*"],
js: ["boot.js"],
runAt: "document_idle",
persistAcrossSessions: true,
}]);
await chrome.storage.sync.set({ autoRepaint: true });
return true;
}
chrome.permissions.onRemoved.addListener(async ({ origins }) => {
if (origins?.includes("https://*/*")) {
await chrome.scripting.unregisterContentScripts({ ids: ["marginal-boot"] });
await chrome.storage.sync.set({ autoRepaint: false });
}
});
Check your understanding · Exercise 5
Marginal ships with activeTab and no host permissions. Which of these work? Select all that apply.
Putting UI inside pages we don't own
Marginal draws three things into the page: the highlight colors on text, a small floating toolbar near the selection, and a note editor. Each faces the same three dangers. The page's CSS can break our UI, our CSS or DOM changes can break the page, and the page's scripts can read whatever we put in its DOM.
The embeddable-widget guide works through the first two dangers in depth: isolation boundaries, inherited CSS, z-index, and iframe sizing. Read it for the general toolkit. This chapter covers only what changes for an extension: we are a guest nobody invited, on every site at once, and we have tools a widget doesn't, such as extension-origin iframes and chrome.scripting.insertCSS.
The naive version shows all three dangers. We insert a div class="toolbar" and a stylesheet with .toolbar { ... }. On a site that already has a .toolbar class, our rules restyle their toolbar, and their div { box-sizing: content-box; font: 11px serif } reset restyles ours.
On a React site, we wrap highlighted text in span elements, and React's next render finds DOM nodes it didn't create, then either throws or removes our spans. And the note the user typed sits in a textarea that any analytics script on the page can read.
Decision How do we render our UI inside a host page?
What we pick: Use both, by sensitivity. The floating toolbar is three buttons with no secrets, so it lives in a closed shadow root: cheap, instant, and isolated in style. The note editor holds what the user writes, so it is an extension iframe inside that shadow root, and the note goes from the iframe straight to the worker without ever touching the page. Password managers such as Bitwarden render their inline autofill menu in extension iframes for the same reason: the list of saved logins must never sit in the page's DOM.
Drawing highlights without touching the page's DOM
Highlights are the riskiest part, because they sit inside the page's own text. Wrapping text in span elements changes the page's DOM tree, which breaks frameworks that own those nodes, breaks the page's own selectors like p > a, and can split words that the page searches.
The CSS Custom Highlight API solves this. We create a Range over the text, add it to a named Highlight, register it in CSS.highlights, and style it with ::highlight(marginal-yellow). The browser paints the color without adding a single node. It is supported in Chrome and Edge 105+, Safari 17.2+, and Firefox 140+.
// sw.ts, once per tab: insertCSS isn't subject to the page's CSP.
await chrome.scripting.insertCSS({
target: { tabId },
css: "::highlight(marginal-yellow) { background-color: #fde68a; }",
});
// content script
const yellow = new Highlight();
CSS.highlights.set("marginal-yellow", yellow);
export function paint(range: Range) {
yellow.add(range); // repaint is the browser's job, no DOM writes
}
export function hitTest(x: number, y: number): Range | undefined {
// Highlights aren't elements, so clicks don't target them; find the range under the pointer.
const pos = document.caretPositionFromPoint?.(x, y);
const caret = pos ? { node: pos.offsetNode, offset: pos.offset } : caretFromRange(document.caretRangeFromPoint?.(x, y));
if (!caret) return undefined;
return [...yellow].find((r) => (r as Range).isPointInRange(caret.node, caret.offset)) as Range | undefined;
}
// WebKit's older equivalent, for browsers without caretPositionFromPoint.
const caretFromRange = (r: Range | null | undefined) => (r ? { node: r.startContainer, offset: r.startOffset } : undefined);
The trade-off is that a highlight is not an element: it can't receive clicks, focus, or a tooltip by itself. So we hit-test on click (as above) to open the note, and we expose the list of highlights to keyboard and screen reader users through the side panel, where each one is a real button. Browsers without the API fall back to wrapping spans in a custom element named marginal-mark with no global styles.
Staying on top without fighting the page
- z-index wars are unwinnable. Pages use
z-index: 2147483647(the largest 32-bit integer) for their own overlays. Instead, our toolbar uses thepopover="manual"attribute andshowPopover(), which puts it in the browser's top layer, above every z-index on the page. Only top-layer elements opened after ours, such as the page's own modal, sit above it. - Page modals make the rest of the page inert. If the page opens a modal
dialog, everything outside it, including our host element, becomes inert and unclickable. We detect an open modal and attach our host inside it, or hide the toolbar and offer the keyboard shortcut. - No layout shift. Our host element is
position: fixedwith zero size; nothing we add takes space in the page's flow. - Clean up. When the page is a single-page app that swaps content, we remove stale toolbars and re-anchor highlights instead of piling up hosts.
Check your understanding · Exercise 6
A news site has body { font: 13px Georgia; color: #333 } button { background: red !important }. Our toolbar renders in a closed shadow root with :host { all: initial }. What happens? Select all that apply.
State and storage across contexts
Since no context lives long, storage is where the real state lives. An extension has more storage choices than a web page, and a few traps that a web developer wouldn't expect.
The biggest one first: a content script's localStorage and IndexedDB belong to the page's origin, not ours. Write the user's highlights to localStorage from a content script on news.example, and you've just given them to news.example's scripts. Content scripts must keep their data in chrome.storage or ask the worker.
| Store | Size | Lifetime | Who can read it | Marginal keeps |
|---|---|---|---|---|
chrome.storage.local | 10 MB (more with unlimitedStorage) | Until uninstall | All extension contexts; content scripts too unless restricted with setAccessLevel | Per-page highlight views, schema version |
chrome.storage.sync | About 100 KB total, 8 KB per item, write rate limits | Synced across the user's signed-in browsers | All contexts | Settings: default color, disabled sites, auto-repaint flag |
chrome.storage.session | 10 MB, in memory | Until the browser closes or the extension reloads or updates | Trusted contexts only by default; not content scripts | Access token, signed-in user |
| IndexedDB (extension origin) | Large: a share of free disk | Until uninstall | Worker and extension pages, never content scripts | Highlight cache for all pages, the outbox, sync cursor |
| Content script variables | Memory | Until the page unloads | That content script | Ranges painted on this page |
Decision Where does the cache of the user's 2,000 highlights live, so pages can repaint without a network call?
What we pick: IndexedDB in the worker as the source of truth, with small storage views for live UI. It answers 'does this page have highlights?' without a network call, keeps the outbox in the same transaction as the cached highlight, and keeps per-key writes small. The indexeddb lesson covers transactions and schema upgrades; the offline-sync-engine guide covers the outbox and pulling changes from the server.
Keeping every view in sync
Say the user has the article open in a tab and the side panel open beside it, and highlights from the web app on their laptop. Three writers, three readers. The rule that keeps this sane: only the worker writes highlights.
The content script and panel send commands. The worker updates IndexedDB and rewrites the page:<urlKey> view, and the panel re-renders from storage.onChanged. The worker also pushes highlights/changed to the tab with chrome.tabs.sendMessage, because content scripts can't read storage.local once we restrict it (below), and the content script repaints.
Settings are the exception: the options page writes storage.sync directly, and every context, including content scripts, reacts to onChanged.
// sw.ts, at startup
chrome.storage.local.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" });
// storage.session is TRUSTED_CONTEXTS by default; leave it that way: it holds the token.
// storage.sync stays readable by content scripts: settings only, nothing private.
// side panel
chrome.storage.local.onChanged.addListener((changes) => {
const key = `page:${currentUrlKey}`;
if (changes[key]) render(changes[key].newValue as PageView);
});
Secrets and encryption
chrome.storage.local and IndexedDB are written to disk unencrypted, in the user's browser profile. Malware on the machine, or anyone with the laptop, can read them. Marginal's notes are not secret enough to justify encryption, but its tokens are, so the short-lived access token lives only in storage.session, which stays in memory.
A password manager goes much further. The vault is stored encrypted, the encryption key is derived from the master password with a slow function such as PBKDF2 or Argon2 using Web Crypto (crypto.subtle, available in the worker), and the unlocked key is held only in memory or storage.session. Auto-lock simply deletes that key. Because storage.session survives worker restarts but not a browser restart, the vault stays unlocked while the user works and locks when they quit.
Check your understanding · Exercise 7
Which storage choices are mistakes for Marginal? Select all that apply.
Network calls and signing in
Every highlight ends up on api.marginal.app, so we need to decide which context calls the API and how it proves who the user is. Both choices have extension-specific traps.
Which context makes the request
A content script can call fetch, but since Chrome 85 its cross-origin requests are treated as if the page made them: the Origin header is the page's origin, and the page's CORS rules apply. Our API would see requests from news.example and would have to allow every origin on the web.
The worker is different. A request from the worker to a host covered by our host_permissions is allowed without CORS, with our extension as the initiator. So the rule is simple: only the worker talks to our API. Content scripts and UI pages send messages, the worker fetches, and the token stays in one place.
Decision How does the extension authenticate to our API?
What we pick: Hand-off from the web app, with tokens. It reuses the login the user already has (including SSO, which the auth-flow guide covers), behaves the same in all four browsers, and keeps the token inside the worker. The access token lives in storage.session; the refresh token in storage.local, rotated on every use, so a stolen copy stops working after the next refresh.
Expired tokens are routine, not an error. The worker's API client catches a 401, refreshes once, and retries the request. If several events hit a 401 at once, they share one refresh promise, so we never spend the same rotating refresh token twice, which the server would read as theft and revoke the whole session.
The auth-flow guide covers refresh-token rotation and what the user sees when a session ends mid-task; the extension only changes where each token lives. If the refresh itself fails, the worker clears the session, sets a signedOut flag in storage, and the toolbar badge shows a small dot. The outbox keeps every unsent highlight until the user signs in again.
Changing other sites' requests: declarativeNetRequest
Some extensions need to change network requests the page makes: ad blockers, privacy tools, and parental-control filters. In MV2 they used blocking webRequest: the browser paused every request and called the extension's JavaScript to decide.
Chrome's MV3 keeps blocking webRequest only for extensions that enterprises force-install by policy. Everyone else uses declarativeNetRequest (DNR): the extension hands the browser a list of rules (match a URL pattern and resource type, then block, redirect, upgrade, or modify headers), and the browser applies them itself without waking the extension.
That is faster and more private, since the extension never sees the requests, but less flexible: rules can't run arbitrary logic, and rule counts are capped. That is why uBlock Origin ships a reduced 'Lite' version on Chrome. Firefox kept blocking webRequest in its MV3 alongside DNR. Marginal doesn't need DNR at all, which is worth saying in an interview: the narrowest design uses neither.
Check your understanding · Exercise 8
Which statements about Marginal's network design are true? Select all that apply.
Architecture, data model, and interfaces
We now have every piece and every rule. The architecture is mostly a decision about where logic lives, because each context has a different lifetime, trust level, and cost. After it come the data model and the two interfaces: the REST API and the message protocol.
Decision Where should the extension's logic and data live?
What we pick: Hub worker with thin views. It mirrors a client and server design inside the browser: the worker is a small local backend with a durable store, and every UI is a client of it. The cost of a message hop and an occasional cold start is small next to a page load, and it buys us one source of truth and a small, safe content script.
| Component | Context | Responsibility | Why here |
|---|---|---|---|
| Message router | Worker | Validate every message, check the sender, dispatch to a handler | One gate for all input, like an API server's middleware |
| API client and auth | Worker | Tokens, refresh, fetch with retry | Only trusted context with host permission for our API |
| Store and outbox | Worker (IndexedDB) | Highlight cache, pending writes, sync cursor | Durable and shared by all tabs |
| Sync scheduler | Worker (chrome.alarms) | Flush outbox, pull remote changes every few minutes | Timers don't survive; alarms do |
| Boot script | Content script, about 3 KB | Check settings and ask 'any highlights here?' | Runs on every page, so it must be tiny |
| Highlighter | Content script, lazy chunk | Anchor ranges, paint, floating toolbar in a shadow root | Loaded only when a page has highlights or the user selects text |
| Note editor | Extension iframe | Edit a note, send it to the worker | Keeps typed notes out of the page |
| Side panel and popup | Extension pages | List highlights, save page, sign-in state | Full APIs, React, our own origin |
Follow one page load through it. The boot script runs at document_idle, reads autoRepaint and the disabled-sites list from storage.sync, and sends highlights/list with the normalized URL. The worker wakes, checks its set of URL keys in IndexedDB, and in most cases answers 'none' in a few milliseconds. The boot script then does nothing more.
Only when the answer has highlights does the worker inject the highlighter chunk, which anchors each one and paints. In the background, the sync scheduler pulls changes made in the web app, so a highlight made on the laptop is in the cache before the user opens the page on the desktop.
Think first
The worker is asleep when the user selects text and presses Alt+H. Walk through what wakes up, in order, and what the user sees while it happens.
The shortcut fires chrome.commands.onCommand, which starts the worker. The worker runs its top-level code, registers listeners, and receives the event, with activeTab access to the tab. It injects the content script if needed and sends highlight/selection to it.
The content script reads the selection, paints it immediately (optimistic UI), and sends highlight/create back. The worker writes to IndexedDB and the outbox, then calls the API. The user sees the color almost at once, even after a cold start; the network call finishes later, and on failure the item stays in the outbox with a small 'not synced' dot in the side panel.
Data model: where a highlight is
The hardest field in the data model is the one that says where a highlight is. A highlight is saved today and must be found again next month, after the site has changed its ads, its layout, and maybe a sentence of the text. Saving a CSS selector or an XPath like /html/body/div[3]/p[7] fails the first time the site adds a banner. Saving a character offset into the page text fails when a comment count changes above it.
The durable answer builds on the W3C Web Annotation Data Model: save several clues and try them in order. A text quote selector stores the exact highlighted text plus about 32 characters before and after it. A text position selector stores start and end offsets in the page's text as a fast first guess.
To re-anchor, we try the position, check that the text there still matches the quote, and if not, search the page text for the quote and use the prefix and suffix to pick between repeats. If even that fails, a fuzzy match within a small edit distance can still find a sentence with a fixed typo. Highlights we can't place are not dropped: the side panel lists them as 'orphaned' with their text.
type Anchor = {
quote: { exact: string; prefix: string; suffix: string }; // ~32 chars each side
position?: { start: number; end: number }; // offsets in page text
};
type Highlight = {
id: string; // client-generated UUID, so retries are idempotent
urlKey: string; // normalized: no hash, no utm_* params, lower-case host
anchor: Anchor;
color: "yellow" | "green" | "blue" | "pink";
note?: string;
createdAt: string; // ISO string: messages are JSON in Chrome
updatedAt: string;
syncState: "pending" | "synced" | "failed";
};
type OutboxItem = { id: string; op: "create" | "update" | "delete"; highlightId: string; attempts: number };
type Settings = { defaultColor: Highlight["color"]; disabledHosts: string[]; autoRepaint: boolean; schema: 3 };
page:urlKey views are disposable copies for the side panel. Each storage area is chosen by lifetime: settings sync, tokens die with the browser session.The server API
The extension uses a small REST API. Writes carry the client-generated id, so a retried POST after a timeout doesn't create a duplicate. Reads are a change feed with a cursor, so the alarm-driven sync asks only for what changed since last time instead of downloading 2,000 highlights.
PUT /v1/highlights/7f3c9a2e-... (idempotent upsert by client id)
Authorization: Bearer eyJ...
{ "urlKey": "news.example/2026/10/space", "anchor": { ... }, "color": "yellow", "note": null }
-> 200 { "id": "7f3c9a2e-...", "updatedAt": "2026-10-21T08:02:11Z", "version": 4 }
GET /v1/highlights/changes?cursor=c_88412&limit=200
-> 200 { "changes": [ { "op": "upsert", "highlight": { ... } }, { "op": "delete", "id": "..." } ],
"cursor": "c_88497", "hasMore": false }
-> 401 { "error": { "code": "token_expired", "retryable": true } }
-> 409 { "error": { "code": "version_conflict", "current": { ... } } }
Inside the extension, the interface is the message union from the messaging chapter, and it is just as much an API as the REST one: it has callers in four contexts, it must be validated, and it must stay compatible across versions, because during an update an old content script can still be talking to the new worker. Each message type gets a version only when its shape changes in a breaking way, and the worker accepts the previous shape for one release.
Check your understanding · Exercise 9
A site inserts a cookie banner paragraph at the top of every article after a highlight was saved. Which anchoring strategies still find the highlight? Select all that apply.
Performance and security on every page
Because our code runs inside other people's pages, every page load carries two budgets: the time we take and the risk we bring. This chapter covers both, performance first.
A web app's performance budget covers its own pages. An extension with auto-repaint spends its budget on everyone else's pages: 32 million page loads a day, nearly all with nothing for us to do. Users can't tell our cost apart from the site's, but Chrome's task manager and profilers can, and so can reviewers who write 'this extension slows down every page'. Every millisecond in the boot path is multiplied by every page load.
Decision How do we ship the content script code?
What we pick: A tiny boot script and a lazy highlighter injected by the worker. It makes the 95 percent case nearly free, and the extra delay lands only on pages where the user is about to see something we drew. Popup and side panel can still use React: they are our own pages, opened on purpose.
document_idle, and on most pages our whole cost is the two small blocks in the middle row. When there is work, it comes in slices under 50 ms so the page stays responsive.Staying off the page's main thread
- Wait for idle. The boot script runs at
document_idle, then defers its work withrequestIdleCallbackso it never competes with the page's first render or Largest Contentful Paint. - Chunk anchoring. Building the page's text to search for 40 quotes on a 30,000-word article can take 20 ms or more. We walk the text with a
TreeWalkerin slices and yield between them withscheduler.yield()where available, orsetTimeout(0), so no task passes 50 ms and the page's Interaction to Next Paint stays good. - Observe narrowly. Single-page apps replace content, so ranges can go stale. A
MutationObserverondocument.bodywithsubtree: truefires for every DOM change on pages like Gmail, thousands of times a minute. We only observe when the page has highlights, batch records and re-anchor once per idle period, and disconnect when the tab is hidden. - Read layout in batches. Positioning the toolbar reads
getBoundingClientRect()once per selection change, never in a loop with writes, to avoid forced layouts on heavy pages. - Respect the opt-outs. Hosts in
disabledHostsexit before any work, and we never run in frames smaller than 200 by 200 pixels, such as ad slots.
The worker has its own costs. A cold start, when Chrome has to start the worker from nothing, typically takes tens of milliseconds, more with a large bundle, because the whole script is parsed and run on every wake. So the worker's entry file stays small and imports heavy handlers only when needed. The popup opens in under 150 ms by rendering the last known state from storage.session first, then refreshing.
Check your understanding · Exercise 10
Users report Gmail feels slower with Marginal installed. Which changes would help? Select all that apply.
Security: a powerful guest in hostile places
An extension holds powers no web page has: access to many sites at once, a token for the user's account, and the ability to inject code. Attackers want those powers. They can come from three directions: the pages we run on, the data we display, and our own release pipeline. The xss, csp, and third-party-scripts lessons cover the web basics; here is how they apply when you are the extension.
| Threat | Example | Defense |
|---|---|---|
| Page spoofs a message | A script on the page sends window.postMessage({ source: "marginal", type: "signIn", code }) to plant its own account | Check event.source, exact event.origin, and shape; only accept login hand-off on our own domain |
| Page fakes user input | The page dispatches a synthetic keydown for Alt+H or a fake click on our toolbar | Shortcuts come from chrome.commands, not page key events; check event.isTrusted on our own listeners |
| Untrusted data rendered as HTML | A highlight's quote or a page title contains img src=x onerror=... and the side panel renders it with innerHTML | Render text with textContent or React's escaping; never innerHTML with page data |
| Content script abused as a proxy | A bug lets a page trigger highlights/list for another site's URL | Worker checks every message against sender.tab.url; least data per answer |
| Secrets in the page | An analytics script reads the note being typed | Note editor in an extension iframe; token never sent to content scripts |
| Malicious update | A stolen developer login or a sold extension ships spyware to all users | Locked-down publishing, two-person release, minimal permissions to limit the blast radius |
No remote code: the extension's own CSP
Manifest V3 forbids remotely hosted code. Every script that runs in an extension page or worker must ship inside the package that the store reviewed.
The Content Security Policy for extension pages starts from script-src 'self'; object-src 'self', and MV3 rejects policies that loosen it to allow remote script URLs or unsafe-eval (only wasm-unsafe-eval is allowed). So no eval, no new Function, no loading a script from our CDN, and no template libraries that compile with new Function at runtime.
Remote data is fine: configuration, feature flags, and translations are JSON that our code interprets, never code itself.
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://api.marginal.app"
}
The note editor page is web-accessible, so any website could try to frame it to trick the user. A frame-ancestors rule would also block our own content script from framing it, so the editor protects itself another way: it accepts messages only from the content script that created it (a random token passed in the URL fragment and checked on every message), and it reads and writes notes through the worker, which checks the tab.
The release pipeline is part of the attack surface
Extensions update silently, so whoever can publish a new version can run code with the extension's permissions on every user's machine within a day or so of approval. This has happened: popular extensions have been sold to new owners who later shipped malicious updates (The Great Suspender, removed by Google in 2021, is the best-known case), and in late 2024 attackers phished the store account of Cyberhaven's extension and pushed a data-stealing version to its users.
Defenses are mostly process: hardware-key two-factor on store accounts, publishing only from CI with a review step, a small and pinned dependency tree, and permissions narrow enough that a bad release can do limited harm. That last point is one more reason for the activeTab-first design.
Check your understanding · Exercise 11
Which of these would break Manifest V3 rules or Marginal's security model? Select all that apply.
Shipping: four browsers, updates, and testing
Shipping an extension means three jobs: building it for four browsers, updating it without losing data, and testing it. Chrome and Edge run the same Chromium extension platform, so one build covers both stores. Firefox and Safari implement the same WebExtensions APIs with real differences. The goal is one source tree, a thin compatibility layer, and a build that emits one package per browser.
| Area | Chrome and Edge | Firefox | Safari |
|---|---|---|---|
| Background | background.service_worker | background.scripts: a non-persistent event page with a DOM | Supports both; packaged inside an app |
| Namespace | chrome.*, promise-based in MV3; browser.* too since Chrome 148 | browser.* with promises; chrome.* also works | browser.*; chrome.* also works |
| Side panel | chrome.sidePanel (114+) | sidebar_action manifest key | No side panel; use the popup |
| Host permissions | Granted at install; users can restrict per site | Shown and granted at install since Firefox 127; users can revoke each one | Users grant access when they visit a site: for one use, for a day, or for all websites |
| Offscreen documents | Yes | No; the event page already has a DOM | No |
Blocking webRequest | Only for policy-installed extensions; others use DNR | Kept, alongside DNR | DNR |
externally_connectable for web pages | Yes | No | Yes, with browser.runtime.sendMessage |
| Distribution | Chrome Web Store, Edge Add-ons | addons.mozilla.org (AMO) | Xcode app on the App Store |
Decision How do we support the differences without forking the code?
What we pick: Shared code with adapters and generated manifests. Feature-detect at runtime (if (chrome.sidePanel)) rather than sniffing the browser name, because support changes with every release. Chrome 121+ and Firefox 121+ also accept a manifest that lists both service_worker and scripts, and each uses the one it supports, which keeps the manifests close.
Firefox's event page deserves a note. It is a background page with a real DOM that is also unloaded when idle, so every rule from the background lifecycle chapter still applies: listeners at the top level, state in storage, alarms instead of timers. Code written for the Chrome worker runs there unchanged; the reverse isn't true if it touches document.
Safari is the biggest step. Apple's xcrun safari-web-extension-converter wraps the web extension in an Xcode project with a small native macOS or iOS app, which you sign with an Apple developer account and ship through App Store review. Users enable the extension in Safari settings. On each site, Safari then asks them to allow it for one use or for one day, unless they allow it on all websites.
That makes the activeTab-first design from the permissions chapter a good fit: Marginal still works when the user clicks, even when they haven't granted any site. Safari's storage differs as well: Apple documents that storage.sync doesn't sync and that storage.local holds 5 MB unless the extension asks for unlimitedStorage.
Check your understanding · Exercise 12
Which cross-browser practices are sound? Select all that apply.
Updates, rollout, and testing
A web app deploys in minutes and every user gets the new version on their next load. An extension goes through store review first, then reaches users on the browser's schedule. Google says most Chrome Web Store reviews finish within a few days but can take up to a few weeks, and Firefox's AMO and Apple's App Store have their own queues.
After approval, Chrome checks for extension updates every few hours, so a release takes a day or more to reach most users. A bad release takes just as long to replace.
Decision How do we ship a risky change, such as a new anchoring algorithm, without risking every user's highlights?
What we pick: Ship dark behind remote flags, and use the store's partial rollout for the package itself when it is large or risky. Flags are the only kill switch that works in minutes, and they don't count as remote code because the code was already in the reviewed package. The feature-flag-sdk guide covers flag evaluation and caching.
What happens on update
- The worker restarts and
storage.sessionis cleared. The access token is gone, so the first API call refreshes it with the refresh token fromstorage.local. Users stay signed in. - Old content scripts are orphaned in Chrome. Content scripts already running in open tabs keep running, but their connection to the extension is gone:
chrome.runtime.sendMessagethrows 'Extension context invalidated'. The boot script catches that, removes its toolbar host, and stops observing. InonInstalledwith reasonupdate, the worker re-injects the new script into tabs that had highlights. - Stored data must be migrated.
onInstalledruns migrations frompreviousVersionup, step by step, and recordsschema: 3in storage. IndexedDB upgrades happen inonupgradeneeded. Each step is idempotent, because the worker can die halfway and run it again. - Rollback means forward. Stores don't let you lower the version number, so 'rolling back' is republishing old code as version 4.2.2. Migrations must therefore leave data that the old code can still read, for at least one release.
Testing an extension
The design makes testing easier: message handlers are plain async functions that take a message, a sender, and their dependencies (store, API client, clock). Unit tests call them directly with fakes, including the cases that matter most: a sender from the wrong tab, a malformed message, an expired token, and a 'restart' simulated by creating fresh handler state between two calls. End-to-end tests load the real built extension into Chromium with Playwright.
import { test as base, chromium, type BrowserContext } from "@playwright/test";
import path from "node:path";
export const test = base.extend<{ context: BrowserContext; extensionId: string }>({
context: async ({}, use) => {
const ext = path.resolve("dist/chrome");
const context = await chromium.launchPersistentContext("", {
channel: "chromium",
args: [`--disable-extensions-except=${ext}`, `--load-extension=${ext}`],
});
await use(context);
await context.close();
},
extensionId: async ({ context }, use) => {
const [worker] = context.serviceWorkers().length
? context.serviceWorkers()
: [await context.waitForEvent("serviceworker")];
await use(new URL(worker.url()).host);
},
});
With the fixture, a test opens a fixture article, selects text, presses Alt+H, and asserts that the side panel at chrome-extension://<id>/panel.html lists the highlight. A second suite runs on a few hostile fixture pages: one with aggressive global CSS, one with a strict CSP, one with a modal dialog, one built with React that re-renders the article. Firefox gets a smaller smoke suite with web-ext run, and Safari a manual checklist per release.
Check your understanding · Exercise 13
Version 4.3 changes the shape of the anchor field. Which steps keep users' data safe? Select all that apply.
Running it in the interview
Extension questions reward candidates who know the platform's rules, because nearly every naive answer breaks one of them. In 45 to 60 minutes you can't cover every chapter, so lead with the rules that shape the design, draw the hub-and-views picture early, and spend your depth on two or three areas the interviewer cares about.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Scope: run on every page or on click? Secrets? Which browsers? | 'Auto-repaint on load is the expensive requirement: it decides permissions and performance.' |
| 5 to 10 | List the contexts and their lifetimes | 'The worker is killed after about 30 seconds idle, the popup when it loses focus. Storage is the only durable state.' |
| 10 to 20 | Draw the architecture: hub worker, thin views, messages, storage | 'Only the worker holds the token and calls the API. Content scripts are untrusted input.' |
| 20 to 35 | Deep dives: permissions, injected UI, lifecycle and sync | 'activeTab first, optional all-sites upgrade. Toolbar in a shadow root, editor in an extension iframe.' |
| 35 to 45 | Data model and anchoring, API, messages | 'Highlights anchor by quote with prefix and suffix, position as a hint.' |
| 45 to 60 | Performance, security, cross-browser, rollout | 'Three-KB boot script, lazy highlighter. No remote code. Remote flags as the kill switch.' |
Draw two pictures and keep pointing at them. First, the contexts as boxes with their lifetimes written underneath, with the worker in the middle. Second, one flow end to end: select text, shortcut, worker wakes, content script paints, outbox, server. Most follow-up questions are 'what happens if X dies in the middle of this flow', and the picture answers them.
Follow-ups to expect
Think first
How would the design change for a password manager instead of a highlighter?
Autofill on every login form is the core feature, so it asks for all-sites access at install and pays for that trust with audits and transparency. The content script detects login forms but never holds the vault; the inline menu showing saved logins is an extension iframe, and the fill happens only after a trusted click in that iframe.
The vault is encrypted at rest with a key derived from the master password; the unlocked key lives in memory or storage.session and auto-lock deletes it. Fill is limited to the exact origin the login was saved for, and never into hidden fields, which defends against pages that try to harvest credentials with invisible forms.
Think first
On a single-page app, the user navigates from one article to another without a page load. What breaks, and how do you handle it?
Declared content scripts run only on real page loads, so the boot script doesn't run again, and the old highlights still point at ranges from the previous article. The worker listens to chrome.webNavigation.onHistoryStateUpdated, or the content script watches for URL changes, and sends 'URL changed' to the boot script, which clears its ranges and asks for the new page's highlights.
The webNavigation permission adds a 'Read your browsing history' install warning in Chrome, so the content-script approach is lighter: check location.href on popstate and when the MutationObserver fires, since the History API itself fires no event on pushState.
Think first
Your extension's API traffic doubles every time Chrome restarts at 9 a.m. Why, and what do you do?
Every browser starts at about the same time, every worker fires onStartup, and every alarm's first run lines up, so millions of clients sync in the same minute. Add random jitter to the first sync (for example 0 to 10 minutes), use the change-feed cursor so each sync is small, and honor Retry-After from the server with exponential backoff.
Check your understanding · Exercise 14
The interviewer asks: 'Why not just keep everything in the content script? It's simpler.' Which points make a strong answer? Select all that apply.
Summary
- An MV3 extension is a set of short-lived contexts (service worker, content scripts, popup, side panel, iframes) that share no memory. They talk by message and keep durable state in storage.
- The service worker is terminated after about 30 seconds idle: register listeners at the top level, keep state in
chrome.storageor IndexedDB, and schedule withchrome.alarms. - Make the worker the hub: it alone holds tokens, calls the API, and writes data. Content scripts, popup, and panels are thin views; content-script messages are untrusted input.
- Permissions are a product decision. Start with
activeTab, and request broad host access at runtime, in context, as an optional upgrade. - Inject UI by sensitivity: a closed shadow root for style isolation, an extension iframe for anything secret, the Custom Highlight API to color text without touching the page's DOM, and the top layer instead of z-index wars.
- Content scripts run on every page: a few-KB boot script, lazy code injected by the worker, idle-time work, and narrow
MutationObserveruse. - No remote code in MV3; ship remote data instead. Remote flags are the fast kill switch, and migrations must survive worker death and rollbacks.
- Plan for four browsers from day one: shared code, adapters, generated manifests, and feature detection.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Extension service worker lifecycle (Chrome for Developers) Idle timeouts and what resets them, by Chrome version.
- chrome.storage API reference Storage areas, quotas, and access levels.
- Message passing (Chrome for Developers) One-time requests, ports, and external messaging.
- The activeTab permission What activeTab grants and which gestures trigger it.
- Manifest V3 migration guide (Firefox Extension Workshop) Where Firefox's MV3 differs from Chrome's.
- background manifest key (MDN) Service workers vs event pages across browsers.
- CSS Custom Highlight API (MDN) Styling text ranges without changing the DOM.
- Web Annotation Data Model (W3C) Text quote and text position selectors for anchoring.
- Chrome Web Store review process Review times and what reviewers look for.
- Manifest V2 support timeline (Chrome for Developers) How and when Chrome turned off Manifest V2 extensions.
- chrome.offscreen API reference Offscreen documents, their reasons, and their limits.
- Cross-origin network requests (Chrome for Developers) Why content scripts fetch as the page and the worker doesn't.
- Assessing your Safari web extension's browser compatibility (Apple) Which APIs and manifest keys Safari supports and where it differs.
Concepts behind it
- Service workersA script the browser runs beside your pages, with no DOM and no memory it can trust, that wakes up for events (fetch, message, push, sync), handles them, and is shut down when idle: a programmable network proxy with sharp limits.
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Third-party scripts and supply chainEvery script you load from a vendor and every package you install runs with your page's full power, so you pick them carefully, pin and verify them, isolate what you can, and pay attention to what they cost in speed and privacy.
- Web components and shadow DOMThe browser's own component model: custom HTML tags with their own lifecycle, a shadow tree that keeps their markup and styles private, and slots for the page's content, so one component works in React, Vue, Angular, or plain HTML.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- CORSThe rules that let a page read responses from another origin, and the headers a server sends to allow it: a browser feature that relaxes the same-origin policy to protect users' data, not a wall that protects your server.