Chrome extension development: a working introduction
Most people who start building a Chrome extension are not trying to become extension developers. They have a specific irritation. A client's admin panel logs them out every hour. Three web apps each hide the one button that matters behind two clicks. A dashboard needs a value pulled from another tab. An extension is the shortest path from that irritation to a fix, and the platform is small enough to learn in an afternoon. The part that trips people up is that a large share of the tutorials still online describe a version of the platform that no longer runs.
Only one platform version is left to learn
Anything written before 2025 needs checking against the current state, because the older extension format has finished its exit. Chrome's own timeline page states where that ended:
All remaining Manifest V2 extensions are removed from the Chrome Web Store. Manifest V2 extensions installed on Chrome 138 or earlier will remain installed, but will be unable to receive any updates and cannot be reinstalled from the Chrome Web Store once removed from Chrome. Source: developer.chrome.com
The same page records that Manifest V2 was disabled for all users on all channels with Chrome 138, on 24 July 2025, with no option to turn it back on.
Two practical consequences follow. First, a tutorial whose manifest begins with a manifest version of 2, or that uses a background page with a persistent flag, is not slightly out of date. It describes something that will not load. Second, code that runs on the older format cannot simply have its version number raised, because the background execution model changed underneath it.
The identifying marks are easy to spot in a code sample. The retired format used background.page or background.scripts with persistent, called chrome.browserAction, and blocked network requests through webRequest. The current format uses background.service_worker, calls chrome.action, and filters requests through declarative rules. Anything matching the first list can be read for ideas and should not be copied.
The smallest extension that does something
An extension is a folder. Chrome reads it, and the only mandatory file is a manifest.
{
"manifest_version": 3,
"name": "Jump to inbox",
"version": "1.0",
"action": { "default_popup": "popup.html" },
"background": { "service_worker": "background.js" },
"permissions": ["tabs"]
}
Four kinds of code can sit alongside it, and knowing which does what removes most of the early confusion.
| Part | Where it runs | Typical use |
|---|---|---|
| Popup | A small page owned by the extension | Buttons and settings that appear on toolbar click |
| Service worker | Its own short-lived context | Reacting to events, calling APIs, storing state |
| Content script | Inside the page being visited | Reading or changing the site's own DOM |
| Options page | A full page owned by the extension | Configuration that is too large for a popup |
The boundary that matters is between the content script and everything else. A content script shares the page's document but not the page's JavaScript variables, and it can only call a limited set of extension APIs. Everything else has to go through message passing to the service worker. Attempts to read a page variable directly from a content script fail for this reason, and the workaround is to inject a script tag into the page and communicate by custom event.
Content scripts are declared in the manifest with a matches list of URL patterns, and each pattern combines a scheme, a host, and a path. Getting these narrow early is worth the effort, because a pattern covering every site both slows down browsing and produces the widest possible install warning. A script that only needs to run on one internal tool should say so. Files that the page itself must be able to load, such as an image or a stylesheet the content script injects, need a separate web_accessible_resources entry, and forgetting it produces a console error that names the file rather than the cause.
Permissions belong in the manifest from the first draft rather than being added when something breaks, because the list drives both what the code can do and what the install prompt says to a user.
Loading it into Chrome without publishing anything
Nothing has to be published to be used. Chrome's own tutorial gives the sequence: open a new tab, type chrome://extensions, turn on Developer mode with the toggle, then use Load unpacked and choose the folder.
The extension appears immediately and behaves like an installed one for the profile that loaded it. It stays until removed, survives restarts, and can be pinned to the toolbar. For a tool built to fix one personal irritation, this is often the entire distribution story.
Reloading after a code change is where beginners lose time, because not everything needs the same treatment. Changes to the manifest and to the service worker require pressing the reload arrow on the extension card. Changes to a content script require the reload and a refresh of the host page. Changes to a popup or options page need neither, since those pages are loaded fresh each time they open.
Two details about unpacked loading catch people out later. The extension is loaded into the profile that was open at the time, so a second Chrome profile on the same Mac sees nothing until the folder is loaded there as well. And an unpacked extension's ID is not guaranteed to stay the same across reloads from a different location, which matters because storage is keyed to the ID. Chrome documents a key field in the manifest for exactly this, describing preserving a single ID as essential during development. Anything that talks to a server, receives messages, or exposes resources to a page should set it early rather than after the ID changes.
Debugging follows the same split. A popup is inspected by right-clicking it and choosing Inspect, which opens a normal DevTools window. The service worker has its own link on the extension card that opens a separate DevTools instance, and errors that happen while no DevTools window is attached collect on the card itself under an Errors button.
The service worker is not a background page
The single biggest behavioural difference from older tutorials is that background code does not stay running. Chrome documents the shutdown rules:
Normally, Chrome terminates a service worker when one of the following conditions is met: After 30 seconds of inactivity. Receiving an event or calling an extension API resets this timer. When a single request, such as an event or API call, takes longer than 5 minutes to process. When a fetch() response takes more than 30 seconds to arrive. Source: developer.chrome.com
Every global variable set in that context disappears when it shuts down. Code that counts something in a module-level variable will work all through testing, when events arrive constantly, and then quietly reset in real use. State belongs in chrome.storage instead, and the local area of that API survives both shutdown and browser restart.
Event listeners have to be registered at the top level of the file, not inside a callback or an async function. When an event arrives while the worker is dormant, Chrome starts the worker and looks for listeners immediately, so a listener registered later never gets the event.
Timers deserve their own warning. setTimeout beyond a few seconds is unreliable, because the worker may be gone before it fires. The chrome.alarms API exists precisely for this, and it wakes the worker to deliver the alarm.
Permissions decide whether anyone installs it
The permission list is the part users actually read, and it is worth designing rather than accumulating.
Broad host access, expressed as a match pattern covering all URLs, produces an install warning saying the extension can read and change all data on all websites. That warning stops a large share of installs, and inside a company it is often what gets an extension blocked by policy.
Narrower options exist. Listing specific origins in host_permissions limits both the warning and the reach. The activeTab permission grants access to the current tab only after the user clicks the extension's icon, with no warning at install time, which covers a surprising number of small tools. Anything genuinely optional can be declared in optional_permissions and requested at the moment it is needed, so the first run stays quiet.
For a tool that will only ever be loaded unpacked by its author, none of this changes what the code can do. It becomes decisive the moment a second person is asked to install it.
Three lines an extension cannot cross
Knowing the ceiling early prevents a week spent trying to build something the platform forbids.
An extension is installed per profile. It cannot see or touch another Chrome profile, and it cannot read the cookies of one profile from inside another. This is deliberate, and it means the common goal of holding two accounts of the same service side by side is not an extension problem at all.
An extension cannot create a second session for a site inside the same profile. Cookies belong to the profile's storage area, so one storage area holds one login per site regardless of what any extension does. Approaches that split storage, such as Firefox's containers, are built into the browser rather than layered on top of it.
An extension also cannot change the browser's window model. Tabs remain tabs, the tab strip remains a strip, and no API turns a web app into an independent window with its own dock presence and notification identity. That kind of separation is a property of the browser itself, which is what a browser that gives every web app its own window provides by design, described under Workspaces, with the services already set up that way listed under Supported apps.
Publishing, or deciding not to
Publishing is a separate project from building. The store requires a developer account, and Chrome's documentation notes that registration involves a one-time fee, paid before anything can be listed. A listing then needs a name, a description, at least one screenshot, an icon, a privacy policy if any user data is handled, and a justification for every permission requested.
Review takes time that varies by the permissions requested, and each update goes through it again. For an internal tool this is usually the wrong trade. The alternatives are to keep loading it unpacked, to publish it as unlisted so that only people with the link can install it, or, inside a managed organisation, to have it deployed by policy.
What to build first
Pick the smallest version of the irritation that started this. One button in a popup that opens a specific page in a specific tab, loaded unpacked, is enough to learn the manifest, the action, the tabs API, and the reload cycle in about an hour.
Then look honestly at what the extension is compensating for. If the answer is that too many accounts and too many services are crammed into one window, an extension will not reach the cause, and a browser that separates them at the window level will. SpaceDeck is built on that separation.
Frequently asked questions
Do old Chrome extension tutorials still work?
Not if they use the retired manifest version. Manifest V2 was disabled for all users with Chrome 138 in July 2025, and the remaining listings were removed from the store on 31 August 2026. A sample that declares a manifest version of 2, uses a persistent background page, or calls chrome.browserAction will not load in a current Chrome.
Does an extension have to be published to be used?
No. Turning on Developer mode at chrome://extensions and choosing Load unpacked installs a folder directly into the current profile, where it persists across restarts. Publishing only becomes necessary to distribute the extension to other people, and an unlisted listing covers small groups without a public store page.
Why does the extension forget its data between actions?
The service worker shuts down after 30 seconds of inactivity, and every global variable in it is lost at that point. Values that need to survive belong in chrome.storage, whose local area persists across shutdowns and browser restarts. Long delays should use chrome.alarms rather than setTimeout, since the worker may not exist when the timeout would fire.
Can an extension keep two accounts of the same service logged in at once?
No. An extension runs inside one profile, and cookies belong to that profile's storage area, so one storage area holds one login per site. Holding two accounts at the same time requires a second storage area, which means a second profile, a container feature built into the browser, or a browser that gives each web app its own session.