cf.completefrontend
My progress
SYSTEM DESIGN · #116

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

RequirementTargetWhy
Page costUnder 10 KB of script on pages with no highlights; under 5 ms of main-thread work after loadOur code runs on 32 million page loads a day. Users blame the extension when pages feel slow.
Never break the pageZero layout shift, no global CSS, no patched page globalsOne broken checkout page earns a one-star review and an uninstall.
Fast surfacesPopup and side panel show content in under 150 msThe popup is opened, glanced at, and closed. A spinner there feels broken.
Survive restartsNo user action is lost when the browser kills the background workerThe browser terminates our background code after about 30 seconds idle. That's normal, not an error.
Least privilegeAsk for the narrowest permissions that still deliver the featureBroad permissions scare users at install and slow down store review.
SecurityPages can never read the user's token or other sites' highlightsOur content script lives next to code from millions of unknown sites.
Cross-browserOne codebase for Chrome, Edge, Firefox; Safari through a wrapper appFour 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?

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.

json
{
  "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 Marginal manifest. Every key maps to one piece described below. Note what's missing: no content script declared for every site (the permissions chapter explains why).

The contexts, one by one

PieceWhere it runsLives forCan useMarginal uses it for
Background service workerIts own process, no DOMSeconds: started per event, stopped after about 30 s idleAlmost every chrome.* API, fetch, IndexedDB; no window, no localStorageAPI calls, auth, sync queue, context menu, shortcuts
Content scriptInside each web page, in an isolated worldAs long as the pageThe page DOM; only a few chrome.* APIs (runtime, storage, i18n)Reading the selection, drawing highlights, the floating toolbar
PopupAn extension page shown from the toolbar buttonUntil it loses focusFull DOM and chrome.* APIsSave the page, show sign-in state
Side panelAn extension page docked beside the tab (Chrome 114+)Until the user closes itFull DOM and chrome.* APIsList highlights and notes for the current page
Options pageAn extension page in a tab or dialogWhile openFull DOM and chrome.* APIsSettings
Offscreen documentA hidden extension page (Chrome 109+)Until the worker closes itDOM APIs, but only chrome.runtime messagingParsing HTML with DOMParser, clipboard
Web-accessible resourcesExtension files a web page may loadWhatever loads themSame as any extension page when framedThe 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.

The pieces of an MV3 extension. On the left, extension pages on the extension's own origin: popup, side panel, options page, and an optional offscreen document. In the middle, the service worker, which wakes per event, reads and writes chrome.storage below it, and exchanges messages with both sides. On the right, a web page tab: our content script in its isolated world and the page's own JavaScript both touch the shared DOM, but not each other's variables.Extension pagesPopupSide panelOptions pageOffscreen docown origin, full APIsmessagesService workerwakes per eventchrome.storageWeb page tabContent scriptisolated worldShared DOMPage JSthe site's worldno shared variables
Every box is its own JavaScript world with its own memory. The only links are messages, shared storage, and, inside a tab, the DOM that the content script and the page both see.

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 setTimeout for 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.addListener runs only after an await, 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?

Keep the worker alive

Ping the worker from a content script or offscreen document every 20 seconds, or hold a WebSocket open, so the 30-second timer never runs out.

Pros

  • Code looks like MV2: globals and timers keep working.
  • No storage reads on each event.

Cons

  • Fights the platform. The browser still restarts the worker on updates, crashes, and browser restarts, so the state-loss bugs remain, only rarer and harder to reproduce.
  • Costs memory and battery for every user all day.
  • Chrome's docs ask extensions not to keep workers alive without need, and it looks suspicious in store review.

Stateless handlers, durable state, alarms

Every handler reads what it needs from chrome.storage or IndexedDB, does its work, writes results back, and returns.

Scheduled work uses chrome.alarms, which the browser keeps outside the worker and fires as an event that wakes it.

Pros

  • Correct by construction: a kill at any moment loses at most the work in progress, which is retried.
  • The worker sleeps most of the day, so it costs nothing.

Cons

  • Every event pays a storage read of a few milliseconds.
  • Alarms fire at most every 30 seconds (Chrome 120 and later) and aren't exact.

Move the long work into an offscreen document

Create an offscreen document and run the sync loop there, since pages don't have the 30-second rule.

Pros

  • A real DOM and normal timers.

Cons

  • Offscreen documents need a reason from a fixed list (such as DOM_PARSER or AUDIO_PLAYBACK) plus a written justification, and 'run my sync loop' isn't on the list. Using one as a keep-alive is the first option in disguise.
  • Only one at a time, and Chrome-only: Firefox and Safari don't have the API.

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

  1. 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 await is too late.
  2. 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 in chrome.storage.local or IndexedDB.
  3. 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. A setTimeout of a few seconds is fine for work inside one event, never for work across events.
ts
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;
  }
}
sw.ts: listeners at the top level; state read on demand; work queued durably.

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.

The extension service worker as a state machine. Stopped goes to Starting when an event arrives. Starting runs the script and goes to Handling once listeners are set. Handling goes to Idle when the handler is done. Idle goes back to Handling on a new event, which resets the timer, or to Stopped after 30 seconds with no events. Handling goes straight to Stopped if one event runs over 5 minutes or the worker crashes.StoppedStartingHandlingIdleevent arriveslisteners setnew eventhandler done30 s, no eventsover 5 min or crash
Stopped is a normal state, not an error, and the worker can enter it from Idle or Handling. Anything kept only in a variable is gone on every arrow into Stopped.

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.

Keep the user in

Start with Sign in, then try the other events. Press Wait 30 s to let Chrome stop the worker.

Extension state. Worker: stopped. Popup: closed. Content script: none. Worker variable user: empty. storage.session user: empty. Outbox: 0, synced: 0. Last messages: none.PopupclosedService workerstoppedContent scriptnonelet user = undefinedlost when stoppedstorage.session: emptyoutbox 0, synced 0last messagesnone yet
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

ChannelFrom, toShapeGood for
chrome.runtime.sendMessageAny extension context to the worker and extension pagesOne request, optional one replyCommands: 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 replyTelling a page to scroll to a highlight
chrome.runtime.connect / chrome.tabs.connectBetween any two contextsA long-lived Port with postMessage, onMessage, onDisconnectStreams, such as progress while the popup saves a long page
chrome.storage.onChangedEvery context that listensBroadcast of key changesSettings, sign-in state, anything many views show
window.postMessageContent script and the page's own JavaScript, or our iframeUntyped, anyone on the page can sendTalking to the Marginal web app's page
externally_connectableListed websites to the worker directlychrome.runtime.sendMessage(extensionId, msg) from the pageOur 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?

Request and reply only

The panel calls sendMessage({ type: "highlights/list", url }) when it opens and after each user action.

Pros

  • Simplest. Each call is independent, so a sleeping worker just wakes up and answers.

Cons

  • The panel goes stale when a highlight is made in the page. Polling every few seconds wastes work and still lags.

Long-lived port

The panel opens a port to the worker; the worker pushes highlight/added events down every open port.

Pros

  • Real push, ordered, and onDisconnect tells each side when the other is gone.

Cons

  • The worker must remember open ports, which is in-memory state. When the worker is restarted the ports drop and the panel must reconnect and refetch.

Storage as the event bus

The worker writes the page's highlights to chrome.storage.local under a key like page:<urlKey>. The side panel listens to chrome.storage.onChanged; content scripts get a pushed message from the worker instead (the storage chapter explains why).

Pros

  • Survives every restart: the data is durable, and any context that opens later reads the current value first.
  • One write fans out to all listeners, in every window.

Cons

  • Each change rewrites a whole key, so keys must stay small (one page's highlights, not the whole library).
  • Not for high-frequency events like cursor moves.

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.

ts
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);
}
messages.ts: every message type in one union, checked before it is handled.

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.

Sequence diagram for saving a highlight. The user presses Alt+H, which wakes the worker. The worker asks the content script for the selection. The content script paints it and sends highlight/create. The worker puts the highlight and an outbox item in IndexedDB, then sends PUT to the API, which fails with a network error. The worker tells the content script the highlight is pending. A minute later an alarm wakes the worker, which retries from the outbox, and the API answers 200 OK.UserContentWorkerIndexedDBAPIAlt+Hget selectionpaint + createput + outboxPUT highlightnetwork errorpending dotalarm: retry200 OK
The paint happens before any network call, so the user sees the color at once. The outbox write comes before the fetch, so a failed request, or a worker killed mid-request, loses nothing: the next alarm retries it.

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, and contextMenus unlock browser APIs. Most show no install warning. A few do, such as tabs (read your browsing history) or history.
  • 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.
  • activeTab grants 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_permissions and optional_host_permissions) are declared in the manifest but requested at runtime with chrome.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?

All sites at install

host_permissions or a declared content script for https://*/*. The content script loads on every page from day one.

Pros

  • Auto-repaint of highlights works immediately, everywhere.
  • Simplest code: the script is just there.

Cons

  • The scariest install warning there is. Fewer installs, enterprise blocks, closer store review.
  • Our script runs on 32 million page loads a day, almost all with no highlights.

activeTab only

No host permissions. Toolbar click, context menu, or the Alt+H shortcut grants access to that tab, and the worker injects the content script with chrome.scripting.executeScript.

Pros

  • No install warning for pages at all.
  • Zero cost on pages the user never interacts with.

Cons

  • Old highlights don't reappear on their own: the user must click once per visit to see them.
  • Access ends when the tab navigates to another site.
  • The side panel can't read the URL of a tab the user switches to (that needs host access or the tabs permission), so it asks the user to press Alt+H there first.

activeTab first, optional all-sites upgrade

Ship with activeTab. On a page where the user has highlights, offer 'Show my highlights automatically on every site'. On click, call chrome.permissions.request({ origins: ["https://*/*"] }), and if granted, register the content script with chrome.scripting.registerContentScripts.

Pros

  • A clean install for everyone. Power users opt in at the moment the value is obvious.
  • Users can grant just one site, such as their favorite blog, instead of all sites.

Cons

  • Two code paths to test: with and without the permission.
  • The in-context prompt still shows the all-sites wording, just later and with a reason.

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.

ts
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 });
  }
});
Upgrading to auto-repaint at runtime. Called from a click handler in the side panel.
Manifest asks for
Permission strategy: active-tab. Install warning: No warning for web pages. highlight on Alt+H: yes. repaint on revisit: no. runs on bank pages unasked: no. quiet install: yes.install dialog saysNo warning for web pagesyes: highlight on Alt+Hno: repaint on revisitno: runs on bank pages unaskedyes: quiet installpermissions: activeTab, scripting
Quiet install. The core action works through the user's gesture; old highlights need one click per visit.

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?

Plain DOM with prefixed classes

Insert elements straight into the page's DOM, with class names like mg-toolbar-x9 and a stylesheet in the page.

Pros

  • Smallest code; works everywhere.

Cons

  • Page styles still reach us through element selectors, inheritance, and !important.
  • Our stylesheet can leak onto the page.
  • The page can read everything.

Shadow root

Insert one custom host element, such as marginal-root, on document.documentElement, attach a closed shadow root, and render our UI inside it with styles adopted into the shadow root only.

Pros

  • Page selectors can't match inside, and our selectors can't match outside: styles are isolated both ways.
  • Same DOM, so positioning next to the selection is easy and cheap.

Cons

  • Inherited properties such as font, color, and line-height still flow in through the host. We reset them with :host { all: initial }.
  • Not a security boundary: keyboard events are composed, so a page listener on document sees every key typed into an input inside our shadow root.

Extension iframe

Inside the shadow root, insert an iframe whose src is chrome.runtime.getURL("note-editor.html"), listed in web_accessible_resources. It runs on our extension origin.

Pros

  • A real security boundary: the page can't read a cross-origin frame's DOM.
  • Full chrome.* APIs inside, so it talks to the worker directly instead of through the content script.

Cons

  • Heavier: a separate document, roughly 20 to 50 ms to load, and sizing it to content needs messages.
  • Each web-accessible file helps sites detect the extension.

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.

Three ways to inject UI compared on four questions. Plain DOM: page CSS leaks in, our CSS leaks out, the page sees keys typed into it, and it costs nothing. Shadow root: only inherited properties come in, our CSS stays in, the page still sees keys, and it costs very little. Extension iframe: nothing comes in or out, the page can't see keys, and it costs 20 to 50 ms to load.page CSS inour CSS outkeys visiblecostplain DOMleaks inleaks outyesnoneshadow rootinheritsnoyestinyext iframenonono20-50 mstoolbar: shadow root; note editor: iframe
A shadow root isolates styles, not secrets: key events from inputs inside it still reach the page. That's why the toolbar uses a shadow root and the note editor uses an extension iframe.

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+.

ts
// 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);
Painting highlights with the Custom Highlight API; the page DOM is untouched.

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 the popover="manual" attribute and showPopover(), 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: fixed with 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.
Wireframe of Marginal on a news article. On the left, the article text with one highlighted sentence painted by the Custom Highlight API. Under it, the floating toolbar in a shadow root with four color swatches and a note button. Below that, the note editor, an extension iframe. On the right, the docked side panel lists two highlights, one orphaned highlight that couldn't be placed, and a Save page button.news.example: Mars dust stormsWhy dust storms growhighlightnoteshadow rootnote editorextension iframeside paneldust storms groworbit data shows1 orphanedSave page
Everything inside the page is ours but not in the page's flow: highlights add no nodes, the toolbar and editor float in one fixed host. The side panel is outside the page entirely, so it never fights the site's layout.

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.

StoreSizeLifetimeWho can read itMarginal keeps
chrome.storage.local10 MB (more with unlimitedStorage)Until uninstallAll extension contexts; content scripts too unless restricted with setAccessLevelPer-page highlight views, schema version
chrome.storage.syncAbout 100 KB total, 8 KB per item, write rate limitsSynced across the user's signed-in browsersAll contextsSettings: default color, disabled sites, auto-repaint flag
chrome.storage.session10 MB, in memoryUntil the browser closes or the extension reloads or updatesTrusted contexts only by default; not content scriptsAccess token, signed-in user
IndexedDB (extension origin)Large: a share of free diskUntil uninstallWorker and extension pages, never content scriptsHighlight cache for all pages, the outbox, sync cursor
Content script variablesMemoryUntil the page unloadsThat content scriptRanges 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?

No cache: ask the server every time

The worker calls GET /highlights?url= on every page load.

Pros

  • Never stale; no client storage code.

Cons

  • 32 million requests a day, almost all returning an empty list.
  • No highlights offline; a 200 ms round trip before any repaint.

Everything in chrome.storage.local

One key per page URL, or one key for the whole library.

Pros

  • Simple API, onChanged events, readable from any context.

Cons

  • Key-value only: no indexes, so 'which pages have highlights' means reading everything.
  • Every get serializes the whole value; one big key gets slow as the library grows.

IndexedDB in the worker, small views in storage

The worker owns an IndexedDB database with a highlights store indexed by urlKey, plus the outbox. A compact set of url keys tells it in microseconds whether a page has any highlights.

For pages that are open, the worker writes a small page:<urlKey> view to chrome.storage.local for the side panel to watch.

Pros

  • Indexed queries, transactions with the outbox, and room for years of highlights.
  • Most page loads end at the set check: no highlights, nothing to do.

Cons

  • Two stores to keep consistent; the IndexedDB copy is the source of truth and views are rebuilt from it.

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.

ts
// 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);
});
Restricting storage.local so a compromised content script can't read every page's highlights.

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?

Reuse the web app's session cookie

The user is signed in on app.marginal.app. The worker calls the API with credentials: "include" and the browser attaches the session cookie.

Pros

  • No separate sign-in: if you're logged in on the site, the extension is too.
  • Sign-out on the site signs the extension out.

Cons

  • Cookie rules for extension-initiated requests differ between browsers and keep changing as third-party cookies get stricter; it works in Chrome with host permissions but is fragile in Safari.
  • Cookie-authenticated endpoints need CSRF protection that assumes a browser page, not an extension.

OAuth in a browser-managed window

chrome.identity.launchWebAuthFlow opens our login page in a popup window and returns the redirect URL with a code; the worker exchanges it, with PKCE, for tokens.

Pros

  • A standard, well-understood flow; tokens belong to the extension.
  • Works in Chrome, Edge, and Firefox.

Cons

  • A second login window even when the user is already signed in on the site.
  • Not available in Safari, which needs another path.

Hand-off from the signed-in web app

The extension opens app.marginal.app/connect-extension. The page, already signed in, asks the server for a one-time code valid for 60 seconds and hands it to the extension.

In Chromium and Safari the page sends it with runtime.sendMessage(extensionId, ...) through externally_connectable; in Firefox a content script on our domain receives it by postMessage with an exact origin check.

The worker exchanges the code for a 15-minute access token and a rotating refresh token.

Pros

  • One click if the user is already signed in; uses the site's existing login, MFA, and SSO.
  • Tokens work the same in every browser.

Cons

  • We build and secure the code exchange ourselves.
  • The refresh token sits in storage.local on disk, so it must rotate and be revocable from account settings.

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.

Sequence diagram for sign-in and refresh between the worker, the Marginal web app, and the API. The worker opens the connect tab. The signed-in web app posts to /ext-codes and gets a one-time code back. The web app sends the code to the worker with sendMessage. The worker exchanges the code and receives an access token and a refresh token. Later the worker calls the API with an expired token and gets 401. It refreshes once, shared by all waiting calls, and retries.WorkerWeb appAPIopen /connect-extensionPOST /ext-codescode, valid 60 ssendMessage(code)exchange codeaccess + refreshGET, token expired401 token_expiredrefresh once, retry
The web app's existing login (with MFA or SSO) does the hard part; the extension only ever sees a 60-second code and then its own tokens. A 401 is routine: one shared refresh, then the original call is retried.

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?

Fat content script

Each content script loads the user's highlights, calls the API, and renders its own UI. The worker only relays.

Pros

  • Fewer messages; each page works on its own.

Cons

  • The token and API client end up inside every hostile page.
  • Big code on every page load.
  • Ten open tabs means ten copies of the same cache, fighting over writes.

Hub worker, thin views

The worker owns the API client, auth, IndexedDB, and the outbox. Content scripts, popup, side panel, and editor iframe are thin views that send commands and render state.

Pros

  • One owner for each piece of data, so no write conflicts.
  • Secrets never leave trusted contexts.
  • Content scripts stay small.

Cons

  • Every action is a message hop, typically a few milliseconds, plus a worker start of tens of milliseconds when it is asleep.

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.

Marginal's architecture in three zones and a server. Top, extension pages: popup, side panel, options, and the note editor iframe. Middle, the worker hub: a message router, the API client, IndexedDB with the outbox, and alarms. Bottom left, the web pages: in each, a small boot script and a lazy highlighter. Bottom right, the Marginal API. Extension pages and page scripts send messages to the router; the note editor saves through the worker; the API client fetches the API with the token.Extension pagesPopupSide panelOptionsNote editorWorker hubRouterAPI clientIndexedDBAlarmscommandsnote saveWeb pagesBoot scriptHighlightermessagesMarginal APIREST + change feedfetch + token
Every arrow into the hub goes through the router, which validates the message and checks the sender. Only the hub talks to the API and holds the token; everything else is a thin view.
ComponentContextResponsibilityWhy here
Message routerWorkerValidate every message, check the sender, dispatch to a handlerOne gate for all input, like an API server's middleware
API client and authWorkerTokens, refresh, fetch with retryOnly trusted context with host permission for our API
Store and outboxWorker (IndexedDB)Highlight cache, pending writes, sync cursorDurable and shared by all tabs
Sync schedulerWorker (chrome.alarms)Flush outbox, pull remote changes every few minutesTimers don't survive; alarms do
Boot scriptContent script, about 3 KBCheck settings and ask 'any highlights here?'Runs on every page, so it must be tiny
HighlighterContent script, lazy chunkAnchor ranges, paint, floating toolbar in a shadow rootLoaded only when a page has highlights or the user selects text
Note editorExtension iframeEdit a note, send it to the workerKeeps typed notes out of the page
Side panel and popupExtension pagesList highlights, save page, sign-in stateFull 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.

Data flow for highlights. Write path, top row: the page DOM gives a selection Range to the content script, which sends highlight/create to the worker, which sends PUT JSON to the API. The worker also upserts the highlight and the outbox into IndexedDB. Read path, bottom row: the API's change feed comes into IndexedDB, IndexedDB rebuilds the page view in storage, and the side panel re-renders on storage onChanged. The worker pushes highlights/changed to the content script, which paints ranges on the page.Rangehighlight/createPUT JSONPage DOMContent scriptWorkerAPIhighlights/changedpaintupsert + outboxchangesSide panelpage viewIndexedDBrebuild viewonChanged
Writes flow one way through the worker. Reads come back through IndexedDB, the single source of truth, then fan out: a storage view for the panel and a pushed message for the content script.

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.

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.

ts
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 };
The client-side entities. Highlights and the outbox live in IndexedDB; settings in storage.sync.
The client data model in two zones. IndexedDB in the worker holds Highlight with id, urlKey, color, note, and syncState; each Highlight has one Anchor with the exact quote, prefix and suffix, and an optional position; OutboxItem points to a highlight with op and attempts. The urlKey index answers which pages have highlights. In chrome.storage: Settings in sync storage, Session with the access token and user in session storage, and page views in local storage, rebuilt from IndexedDB.IndexedDB (worker)Highlightid, urlKeycolor, notesyncStateAnchorquote.exactprefix, suffixposition?OutboxItemop, highlightIdattemptspoints toindex: urlKeychrome.storageSettings: syncdefaultColordisabledHostsautoRepaintSession: sessionaccessTokenuserpage:urlKey: localview for panelrebuilds
IndexedDB is the source of truth; the 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.

http
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": { ... } } }
Write, change feed, and the error shape every handler expects.

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?

One bundle with the whole UI

Highlighter, toolbar, anchoring, and React in one content script of about 120 KB, injected on every page.

Pros

  • Simple build; everything is ready instantly.

Cons

  • Parse and compile cost on every page, roughly 10 to 30 ms on a mid-range laptop, for a page with no highlights.
  • Memory per tab for code that never runs.

Tiny boot script, lazy highlighter

A 3 KB boot script checks settings and asks the worker one question. Only if the page has highlights, or the user selects text and triggers us, does the worker inject the highlighter with chrome.scripting.executeScript({ files: ["highlighter.js"] }).

The toolbar is written with plain DOM or Preact (about 4 KB), not React.

Pros

  • The common case costs one small script and one message.
  • No extra web-accessible files, unlike a dynamic import() from the content script.

Cons

  • The first highlight on a page waits a few tens of milliseconds for the chunk.
  • Two scripts to keep in step across versions.

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.

A page load timeline with three rows. The page row: parse, render, and the page's own JavaScript, then document_idle. The no-highlights row, 95 percent of loads: a tiny boot script and one question to the worker, then done. The has-highlights row: boot, the worker injects the highlighter, then anchoring runs in three short slices with gaps, yielding between them.pageparserenderpage JSdocument_idlenone herebootaskdonehighlightsbootinjectanchor in slices, yield between
Nothing of ours runs before 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 with requestIdleCallback so 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 TreeWalker in slices and yield between them with scheduler.yield() where available, or setTimeout(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 MutationObserver on document.body with subtree: true fires 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 disabledHosts exit 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.

ThreatExampleDefense
Page spoofs a messageA script on the page sends window.postMessage({ source: "marginal", type: "signIn", code }) to plant its own accountCheck event.source, exact event.origin, and shape; only accept login hand-off on our own domain
Page fakes user inputThe page dispatches a synthetic keydown for Alt+H or a fake click on our toolbarShortcuts come from chrome.commands, not page key events; check event.isTrusted on our own listeners
Untrusted data rendered as HTMLA highlight's quote or a page title contains img src=x onerror=... and the side panel renders it with innerHTMLRender text with textContent or React's escaping; never innerHTML with page data
Content script abused as a proxyA bug lets a page trigger highlights/list for another site's URLWorker checks every message against sender.tab.url; least data per answer
Secrets in the pageAn analytics script reads the note being typedNote editor in an extension iframe; token never sent to content scripts
Malicious updateA stolen developer login or a sold extension ships spyware to all usersLocked-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.

json
"content_security_policy": {
  "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://api.marginal.app"
}
The strictest useful policy for Marginal's extension pages. Connections only to our API.

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.

AreaChrome and EdgeFirefoxSafari
Backgroundbackground.service_workerbackground.scripts: a non-persistent event page with a DOMSupports both; packaged inside an app
Namespacechrome.*, promise-based in MV3; browser.* too since Chrome 148browser.* with promises; chrome.* also worksbrowser.*; chrome.* also works
Side panelchrome.sidePanel (114+)sidebar_action manifest keyNo side panel; use the popup
Host permissionsGranted at install; users can restrict per siteShown and granted at install since Firefox 127; users can revoke each oneUsers grant access when they visit a site: for one use, for a day, or for all websites
Offscreen documentsYesNo; the event page already has a DOMNo
Blocking webRequestOnly for policy-installed extensions; others use DNRKept, alongside DNRDNR
externally_connectable for web pagesYesNoYes, with browser.runtime.sendMessage
DistributionChrome Web Store, Edge Add-onsaddons.mozilla.org (AMO)Xcode app on the App Store

Decision How do we support the differences without forking the code?

A codebase per browser

Copy the project for Firefox and Safari and patch the differences in each copy.

Pros

  • Each copy can use every native feature freely.

Cons

  • Every fix lands three times; within months the copies drift and users on one browser get bugs fixed elsewhere.

Shared code, adapters, per-browser manifests

Write against the promise-based browser.* namespace. Firefox, Safari, and Chrome 148 and later all provide it; for older Chrome, a one-line alias (globalThis.browser ??= chrome) or webextension-polyfill fills the gap.

Put each real difference behind an adapter: openNotesSurface() opens the side panel in Chrome, the sidebar in Firefox, and the popup in Safari.

Generate manifest.json per target at build time; frameworks such as WXT do this for you.

Pros

  • One fix, every browser.
  • Differences are listed in one folder, which doubles as documentation.

Cons

  • Lowest-common-denominator pressure; features must be detected, not assumed.
  • Three or four builds to test on every release.

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.

The cross-browser build. Shared source with adapters goes into one build step, which emits four packages: chrome with a service worker, edge with the same package, firefox with background scripts, and safari wrapped in an Xcode app. They ship to the Chrome Web Store, Edge Add-ons, addons.mozilla.org, and the App Store.shared src+ adaptersbuildchromeservice_workeredgesame packagefirefoxbackground.scriptssafariXcode wrapperWeb StoreEdge Add-onsAMOApp Store
Differences live in adapters and in the generated manifest, not in forks. Chrome and Edge share one package; Safari adds a native wrapper and Apple's review.

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?

Ship to everyone and watch

Publish the new version to 100 percent and fix forward if errors rise.

Pros

  • Simplest process.

Cons

  • A fix needs another review cycle, so a bad release can live for days on every machine.

Store partial rollout

Chrome Web Store lets items with more than 10,000 seven-day active users publish an update to a percentage of users first, then raise it.

Pros

  • Real users, real browsers, limited damage while we watch error rates.

Cons

  • Raising or holding the percentage is manual, and the old code is still there for the rest.
  • Not the same in every store.

Ship dark, turn on with remote flags

Both algorithms ship in the package. A JSON flag from our API, cached in storage, chooses which one runs, per user cohort.

Pros

  • Turn it off in minutes without a new review: the code was already reviewed, only data changes.
  • Same mechanism in all four browsers.

Cons

  • Both code paths ship and must be tested until the old one is removed.

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.session is cleared. The access token is gone, so the first API call refreshes it with the refresh token from storage.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.sendMessage throws 'Extension context invalidated'. The boot script catches that, removes its toolbar host, and stops observing. In onInstalled with reason update, the worker re-injects the new script into tabs that had highlights.
  • Stored data must be migrated. onInstalled runs migrations from previousVersion up, step by step, and records schema: 3 in storage. IndexedDB upgrades happen in onupgradeneeded. 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.
Release timeline. Store row: submit, review taking a few days and sometimes weeks, partial rollout at 5 percent, then 25 percent, then 100 percent; remote flags can turn a feature off in minutes at any point. Browser row: the update is found, the worker restarts, migrations run, and the worker re-injects content scripts into open tabs. Session storage is cleared and old content scripts are orphaned.storesubmitreview5%25%100%days, sometimes weeksflags: off in minutesbrowserupdaterestartmigratere-injectstorage.session clearedold content scripts orphaned
The store row is measured in days, the browser row in seconds. Remote flags are the only lever that works faster than a review.

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.

ts
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);
  },
});
A Playwright fixture that loads the unpacked extension and finds its service worker.

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.

MinutesWhat to doWhat to say out loud
0 to 5Scope: 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 10List 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 20Draw 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 35Deep 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 45Data model and anchoring, API, messages'Highlights anchor by quote with prefix and suffix, position as a hint.'
45 to 60Performance, 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?

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?

Think first

Your extension's API traffic doubles every time Chrome restarts at 9 a.m. Why, and what do you do?

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.storage or IndexedDB, and schedule with chrome.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 MutationObserver use.
  • 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next