Explain to me how and what Chrome extensions work.
The problem they solve. A website can only change itself. Your portfolio's JavaScript can't touch a Gmail tab, block ads on YouTube, or read the address of whatever tab you're on. Browsers enforce this strictly, because otherwise any site could spy on every other site. Extensions are the sanctioned exception: small programs you install into Chrome itself, which you allow to reach across sites and into browser features a page can't touch.
They're just web code in a folder. HTML, CSS and JavaScript, the same as you'd write in Next.js minus the framework. Chrome reads a folder with a manifest.json (required), a background.js (the service worker), a content.js that runs inside web pages, a popup.html for the little window under the toolbar icon, and the icons. Each extension gets its own origin, chrome-extension://abcdefgh…/, so its files load like a tiny private website.
The [[manifest]] is the contract. It says what the extension is and what it may do: permissions such as storage and tabs, the sites it may reach (https://*.github.com/*), its service worker, which pages get its content script, and its popup. Those permissions produce the warning at install time, "Read and change your data on github.com", and Chrome won't let the code do anything it didn't declare. manifest_version: 3, MV3, is the current format. MV2 is gone from Chrome.
Three kinds of code, running in three different places. This is the part that makes it click.
- The [[content-script]] is injected into matching pages. It can read and edit the page's : hide elements, add buttons, restyle things. It runs in an "isolated world": it sees the same DOM as the page, but the page's JavaScript variables are invisible to it and the other way round, so a malicious site can't hijack the extension by messing with its globals. It gets only a few
chrome.*APIs. - The [[service-worker]] is the brain. It has the powerful APIs,
chrome.tabs,chrome.storage,chrome.contextMenus,chrome.declarativeNetRequestfor blocking requests. It has no DOM, and Chrome stops it after about 30 seconds idle and wakes it when an event fires, so you can't keep state in a variable: you save tochrome.storage. - The popup is a normal HTML page that exists only while it's open. Click away and it's destroyed.
They talk by passing messages. Because the pieces live in separate places, they communicate like separate processes. The content script calls chrome.runtime.sendMessage({ type: "pageTitle", title: document.title }), and the worker's chrome.runtime.onMessage listener gets it, with the tab it came from. It's the same idea as your Next.js frontend calling an API route: separate environments, JSON going back and forth.
How an ad blocker works under MV3. It used to intercept every request in JavaScript and decide whether to block it. MV3 replaced that with declarativeNetRequest: the extension hands Chrome a list of rules like "block requests to *://ads.example.com/*", and Chrome's own network code enforces them. The extension never sees your traffic. That's better for privacy and speed, and it's why some blockers complained, since a fixed list is less flexible than code.
Installing and shipping. For development, open chrome://extensions, turn on Developer mode, click "Load unpacked" and pick the folder. To distribute, you zip it and upload it to the Chrome Web Store, where Google reviews it and signs it as a .crx file. Updates roll out automatically, a bit like a Vercel deploy users pick up without doing anything.
One real risk. An extension with <all_urls> can read every page you visit, your bank's included. Extensions get sold to new owners who push malicious updates. Treat that install warning seriously.
The pattern: an extension is a mini website with a manifest that buys it extra permissions. Its code is split across a page-side script that touches the DOM and a browser-side worker that touches Chrome's APIs, and the two pass messages.
"Its code is split across a page-side script that touches the DOM and a browser-side worker that touches Chrome's APIs, and the two pass messages." Can you give me more examples of this concept? I just want to wrap my head around it. You can use very simple extensions that we know, or just build up a simple explanation, I don't care. I just want to see this concept and make it a little more solid. I have even given you some examples of extensions I use.
The rule that decides everything. Ask two questions about any feature. Does it need to see or change what's on the page? That's the content script. Does it need something only Chrome controls, like tabs, the toolbar icon, screenshots, storage or network rules? That's the service worker. If it needs both, a message crosses between them. Every example below is that rule applied.
Your camera icon, a screenshot tool. Taking a picture of a tab is a browser power. Only the worker can call chrome.tabs.captureVisibleTab(), which returns an image of what's on screen; a content script can't take a screenshot at all. But the worker has no idea how tall the page is or how to scroll it. So a full-page screenshot is a back-and-forth loop. Neither half could do it alone: the page side knows the page, the browser side holds the camera.
“How tall is the page?”
“4,200 px, and the window shows 900”
Your icon with the "18" badge. The number lives on the toolbar, which belongs to Chrome: chrome.action.setBadgeText({ text: "18" }). Where the 18 comes from depends on what's counted. If it's something on a page, like unread items, a content script counts them in the DOM and sends the number over. If it's tabs, there's no content script at all: the worker calls chrome.tabs.query({}) and counts. Plenty of extensions only have one half.
A password manager. This one shows why the split is a security feature. The content script finds <input type="password"> and tells the worker there's a login form on github.com. The worker reads the encrypted vault from storage and sends back the one login for that site, and the content script fills the two inputs. The vault never lives inside the page, and even the content script only gets the one credential, after the worker decides it should.
A dark mode extension. Mostly page side: the content script injects CSS that inverts colours. The worker only holds your settings, like the sites you've excluded, and the content script asks for them on load. Flip the toggle in the popup and the popup tells the worker, the worker saves it and tells the content script, and the content script removes the CSS. Three pieces, one click, two messages.
An ad blocker. Mostly browser side. A request to ads.example.com is blocked before anything reaches the page, so the worker hands rules to declarativeNetRequest. But ads served from the site's own domain can't be blocked by address, so a content script hides them with display: none. Network job to the worker, cosmetic job to the page.
The content script is the extension's hands inside one page, and the worker holds Chrome's keys. Once you see that, you can guess how almost any extension in your toolbar is built by asking which half each feature needs.
Oh, I have a fun one for you, Claude. I have the Claude extension. I always ask Claude to open a new tab and use the tab to debug a website I am currently building.
Your Claude extension fits the model, plus one extra power. It has the usual pieces. The side panel is the interface, a longer-lived cousin of the popup. The worker manages tabs: a session gets its own tab group, and every tab the extension opens, reads and screenshots stays in it, which is why your debugging tab shows up in its own coloured group. The interesting part is a permission most extensions never ask for: debugger. With it, Claude really operates your browser, clicking, typing and taking screenshots.
`debugger` means the [[cdp|Chrome DevTools Protocol]]. When you open DevTools and click round the Console or Network tab, DevTools talks to the page over CDP: JSON messages over a connection, with commands such as Page.captureScreenshot, Input.dispatchMouseEvent (click at x=420, y=310), Input.dispatchKeyEvent, Runtime.evaluate, and events such as Runtime.consoleAPICalled and Network.responseReceived. The chrome.debugger API lets an extension speak CDP to a tab, so it becomes an invisible DevTools. That's why Chrome shows the bar saying it "started debugging this browser" while Claude works.
Why not just use a content script? A content script can call button.click(), but that's a synthetic event, and the browser marks it event.isTrusted === false; some sites ignore those. A CDP mouse event goes through Chrome's real input pipeline, so the page can't tell it from your hand on the trackpad. CDP also sees what a content script can't, like console errors and failed requests, which is what makes it good for build, test, verify, exactly what you're doing when you debug your site.
button.click()event.isTrusted === falseThe loop when you ask Claude to debug your site. The model, on Anthropic's servers, gets a screenshot and the console errors, and replies with an action, "click (420, 310)". The extension in your Chrome carries it out over CDP on your localhost:3000 tab, and sends back the next screenshot. Screenshot, decide, act, screenshot again. Claude "sees" your site as images plus whatever text it reads from the DOM and console. Driven from Claude Code instead, the extension uses nativeMessaging, a Chrome API that lets an extension talk to a program installed on your machine.
I have heard of the concept of a headless browser, where Claude can use my browser without seeing what the actual website looks like, and it uses something called Playwright. We could expand a little bit more on that now that we have this new concept built up.
Now [[playwright]]. It's a Node library from Microsoft that drives a browser from code, and here's the satisfying part: for Chromium, it controls the browser over the same CDP. Same messages, different sender. A few lines launch Chromium, listen for the page's console, go to http://localhost:3000, click "Contact", and save a screenshot.
One correction on "headless". It doesn't mean Claude uses your browser without seeing it. It means Playwright launches a separate browser with no window drawn on screen (). It isn't your Chrome at all: it's a fresh Chromium with an empty profile, so no cookies, no logins, no extensions. The page still renders fully in memory, which is why page.screenshot() works with no window.
The "without seeing" part is real, but it's a different setting. Playwright's MCP server, which is how Claude Code typically uses Playwright, reads the page's by default instead of screenshots: a text outline such as heading "Roger Kinoti" [level=1], link "Work", button "Contact". So there are two separate questions. Is there a window, headed or headless? And does Claude take in the page as pixels or as a text tree? You can mix them either way.
chrome.debugger, from the extensionFor your portfolio. Claude in Chrome is good when the bug depends on your real browser, like being logged into Vercel or a cached service worker. Playwright is better once you want the same check on every deploy, say "the contact form submits and no console errors appear", because it starts clean every time and needs no window.
CDP is the steering wheel for Chrome. DevTools, Claude in Chrome and Playwright all grab the same wheel. The differences are whose browser it is, whether there's a window, and whether Claude reads the page as a picture or as a tree.
Then, if V8 is JavaScript on the browser, which means it's written in JavaScript, then what is WebKit?
Quick fix first. V8 isn't written in JavaScript. It's written in C++, and its job is to run JavaScript: it reads your JS, compiles it to machine code and runs it. V8 is the JavaScript , the part that understands const, functions and await.
WebKit is the other half of a browser. A JavaScript engine alone can't show a page. Something has to read the HTML, apply the CSS, work out where every box goes and paint pixels: the rendering engine. WebKit is Apple's, and it powers Safari. Every major browser is a pair like this (Brave, Chromium and the engine).
The two halves talk constantly. When your JS runs document.querySelector(".hero").style.opacity = 0, V8 doesn't own the page. It calls across into Blink, which holds the DOM, updates the element, then lays out and repaints. That's the same bridge a content script uses when it edits a page: the DOM lives in the rendering engine, and the JS engine reaches into it.
Chrome and Safari are cousins. Chrome ran on WebKit too, until Google forked it in 2013 and called its copy Blink. They share old code but have drifted apart for over a decade, which is why a CSS feature can work in Chrome and break in Safari. Edge, Arc, Brave and Opera all use Blink and V8 under their own interface.
Why WebKit matters to a designer. On iPhone, Apple long required every browser to use WebKit, so Chrome on iOS was Safari's engine in a Chrome skin. The EU's Digital Markets Act opened that up for users in the EU, though I'd check whether any major browser has actually shipped its own engine on iOS yet. For most visitors on iPhones, your portfolio still renders in WebKit whichever icon they tapped.
Tie-in with Playwright. It can launch three browsers, chromium, firefox and webkit. So if your site breaks only on Safari, you can test WebKit's rendering from a Node script: webkit.launch().
How does Playwright connect to Chromium over CDP?
It starts by launching Chromium itself. chromium.launch() starts a Chromium that Playwright downloaded during npx playwright install, as a child . The important flag is --remote-debugging-pipe: don't listen on a network port, talk CDP over two pipes with the process that started you. A is an operating-system channel between a parent and a child process, an invisible wire. Chromium reads commands from file descriptor 3 and writes replies to file descriptor 4.
One friendly call becomes a burst of commands. Finding the element runs Playwright's selector engine inside the page with Runtime.callFunctionOn. Waiting re-checks it across animation frames until it's visible, enabled and not moving, so a button mid fade-in isn't clicked in the wrong spot. Scrolling is DOM.scrollIntoViewIfNeeded, and measuring is DOM.getContentQuads, the element's corners on screen. The click is two Input.dispatchMouseEvent calls, mousePressed then mouseReleased, at the middle of that box: the same trusted input Claude in Chrome uses.
await page.click("text=Contact")- Find#41Runtime.callFunctionOnRuns Playwright's selector engine inside the page, which returns the element
The other way in, over a [[port]]. You can attach Playwright to a Chrome that's already running. Start Chrome with --remote-debugging-port=9222 and it opens a WebSocket at an address like ws://127.0.0.1:9222/devtools/browser/<id>; then chromium.connectOverCDP("http://localhost:9222"). Same JSON, different wire. The pipe is the default because it's faster and no other program on your machine can connect to it. An open port means anything local can take control of that browser, so only open one on purpose.
Firefox and WebKit don't speak CDP. It's Chromium's protocol. For the other two, Playwright ships its own patched builds: Firefox gets a protocol Playwright's team calls Juggler, and WebKit gets patches to its own inspector protocol. Your script doesn't change, because the library hides the difference. That's why you need Playwright's downloaded browsers rather than the Safari or Firefox on your Mac.
Playwright launches Chromium, opens a private pipe to it, and sends numbered JSON commands down it. One call like page.click() becomes a short burst of CDP commands that find, wait, measure, and then press the mouse for real.