Inject JavaScript into a website: the safe way to patch a web app

There is one screen in the tool everyone at work lives in that is wrong. A column that should be sorted by date is sorted by name, a modal steals focus, a keyboard shortcut collides with a system one, a list of two hundred rows has no filter. Six lines of JavaScript would fix it, and the vendor's roadmap will not.

Pasting those six lines into the Console proves the fix in thirty seconds. The interesting part starts afterwards, when the tab is reloaded and the fix is gone. Everything that separates a one-off experiment from a patch that is still working in six months comes down to three questions: where the code lives, which JavaScript context it lands in, and what happens on the next deploy.

The five places the code can live

These are the options on a Mac, ordered by how long the code survives. Nothing else is really a distinct mechanism, and choosing wrongly here is the reason most attempts feel fragile.

Where it lives Runs again by itself Survives a reload Reaches the page's own variables Who else can use it
Console line No No Yes Nobody
DevTools snippet No, run on demand No Yes One machine
Bookmarklet No, one click No Yes, unless the page policy blocks it Anyone given the bookmark
Userscript in a manager Yes Yes Depends on configuration Anyone who installs both
An extension of its own Yes Yes Only in the main world Anyone the extension is shared with

The Console is for proving it, snippets are for repeating it

The Console is the right place to find out whether an idea works, and the wrong place to keep it. Chrome's own documentation points at the next step without ceremony: "If you find yourself running the same code in the Console repeatedly, consider saving the code as a snippet instead."

Snippets sit in the Sources panel, and two properties make them worth knowing. They "have access to the page's JavaScript context", which means a snippet can read the application object the page built, not just the DOM. And they can be run without hunting for them: Command and Enter in the editor, or Command and O to open the Command Menu, then the exclamation mark followed by the snippet's name. The documentation describes them as an alternative to bookmarklets, which is a fair summary of the ergonomics.

The limit is where they are stored. "DevTools doesn't sync snippets with settings and they can't be accessed via the file system", per developer.chrome.com. A snippet is therefore not a file, not in version control, not on the second Mac, and not something a colleague can be sent. It is a personal shortcut, and treating it as a shared fix is how a team ends up with five slightly different versions of the same patch.

When the change belongs in a file, not in a statement

For a change to a script or a stylesheet the page loads, there is a middle rung. Local overrides work like this: "When you make changes in DevTools, DevTools saves a copy of the modified file to a folder you specify. When you reload the page, DevTools serves the local, modified file, rather than the network resource."

That is genuinely persistent across reloads and it operates on real files in a real folder, which is a better home for anything longer than a few lines. Three documented caveats decide whether it fits. DevTools "doesn't save changes made in the DOM tree of the Elements panel", so structural edits made by hand in Elements are not captured. CSS edited in the Styles pane is not saved when the source of that CSS is an HTML file. And "Cache is disabled when Local overrides are enabled", which changes how the page loads while the mechanism is switched on. The full list is on developer.chrome.com.

Isolated world or main world, the distinction behind most failures

This is the part that sends people back to the Console convinced their code is broken. An extension's content script does not run in the same JavaScript context as the page, and Chrome states the consequence directly:

An isolated world is a private execution environment that isn't accessible to the page or other extensions.

The same page is blunter still about mutual visibility: "None of these (web page, content scripts, and any running extensions) can access the context and variables of the others." The world property "Defaults to ISOLATED", per developer.chrome.com.

The practical rule follows from that. Anything that only reads and writes the DOM works fine in the isolated world, and should stay there, because the page cannot interfere with it and nothing leaks the other way. Anything that needs the page's own objects, a global store, a charting instance, an event bus, a framework hook, has to run in the main world instead. Code that returns undefined for a global that is plainly visible in the Console is almost always code in the wrong world.

Moving to the main world has a price that is easy to miss: when a script runs there, "the CSP of the page applies". A site with a strict Content Security Policy can therefore refuse the very thing the script is trying to do, and the failure surfaces as a policy violation rather than as a JavaScript error. Chrome's newer userScripts API carves out a third environment for this reason, a USER_SCRIPT world that is "exempt from the page's CSP" while remaining separate from the page's own globals.

Timing, and the single page app problem

Injection timing is a documented three-way choice, and the default is the latest of the three. document_idle is the preferred default, where "The browser chooses a time to inject scripts between document_end and immediately after the window.onload event fires." document_start runs "after any files from css, but before any other DOM is constructed". document_end runs "immediately after the DOM is complete, but before subresources like images and frames have loaded". Programmatic injection follows the same convention: executeScript() runs "at document_idle, or immediately if the page has already loaded", per developer.chrome.com.

For a patch that hides an element or rewrites a label, document_start is tempting and usually wrong, because the element does not exist yet. For a patch that must beat the page to a global, it is the only option that works.

Then there is the case the documentation cannot solve, which is most work software. A single page application loads one document and then replaces views without another navigation, so an injection point that fires once per document fires once per day. Three patterns survive that. Watch for the element with a MutationObserver scoped to a container that does not get replaced. Attach one delegated listener high in the tree rather than a listener per row. Make the patch idempotent, with a marker such as a data- attribute, so running it forty times leaves one result instead of forty. A patch that works on first load and breaks after the third click is nearly always a patch that assumed one view per document.

What the browser asks for before it will run anything

Permissions are where a userscript and a private extension diverge, and both have moved recently.

A content script or a programmatic injection needs the "scripting" permission plus host permissions for the pages it touches, or "activeTab", which grants access to the current tab temporarily instead of standing access to a domain. For a patch aimed at one internal tool, a single narrow host permission is both easier to justify and less to lose.

Chrome's userScripts API asks for more deliberate consent. It requires the "userScripts" permission and host permissions, needs Chrome 120 or later with manifest V3, and carries a user-side switch that changed version by version: before Chrome 138 the user has to enable Developer mode, and from Chrome 138 there is an Allow User Scripts toggle on each extension's own details page, as set out on developer.chrome.com. Anything built on that API is therefore something the person running it has to switch on knowingly, which is a feature rather than an obstacle.

One implementation detail catches people out when they move from the Console to an extension. A function handed to executeScript() "will be serialized, and then deserialized for injection", so "any bound parameters and execution context will be lost". Values from the surrounding scope have to be passed through the args property. Code that worked in a snippet because it closed over a variable will quietly receive nothing once it is injected.

Bookmarklets deserve one line of caution here too. A bookmarklet is a navigation to a javascript: URL, and the Content Security Policy specification treats such a navigation as inline script and runs an inline check against the page's policy, per w3c.github.io. On a site with a strict policy, a bookmarklet that works everywhere else can simply do nothing.

Writing a patch that survives the next deploy

Most patches do not break because the technique was wrong. They break because the site shipped on Tuesday.

Anchor on things that change slowly. Generated class names, deep child indexes and positional selectors are the first casualties of a redesign. Visible text, aria-label, role, data-testid attributes and URL shape last much longer, and a selector built from two weak signals is stronger than one built from a brittle one.

Fail quietly and locally. Wrap the patch so that an exception in it cannot take the page with it, check that the element exists before touching it, and give up rather than guessing when the structure is unrecognisable. A patch that does nothing on the day of a redesign is a good outcome. A patch that throws on every render, or that clicks a button that has moved, is not.

Keep the code where it can be read and diffed. That means a file, with a comment at the top saying which page it patches, what it changes and why, and what the vendor would have to fix for it to be deleted. Patches accumulate, the reason for each one evaporates in a month, and the difference between a small useful collection and a pile nobody dares touch is entirely in whether that comment exists.

Where injection is the wrong tool

Three cases come up constantly and none of them is solved by script.

The first is two accounts of the same service. No amount of injected JavaScript separates two sessions that share a cookie jar, because the collision is in storage rather than in the page. That is a container problem, and it is fixed by giving each account its own session, which is also why each app in its own window removes a class of bug that scripting cannot.

The second is anything touching money, credentials or irreversible actions. Automating a click that submits, pays, deletes or sends is a patch whose failure mode is not a broken layout.

The third is code from strangers. A line pasted into the Console of an authenticated tab runs with that session's full authority, and so does a userscript installed to save five minutes. Reading the code before running it is not paranoia, it is the entire security model of this technique.

What to write first

Prove the fix as one Console line, then move it straight into a file rather than a second Console line, and decide at that moment whether it needs the page's own globals or only the DOM. If the tool is one used every day, the tidiest home for the patch is the app itself: a browser that keeps each web app in its own window can hold a per-app script and stylesheet, so the patch loads with the app and nowhere else, which is what SpaceDeck does with its per-app injection.

Frequently asked questions

How can JavaScript be added to a website permanently, without an extension?

Nothing outside an extension or a userscript manager runs by itself on every load. DevTools snippets and bookmarklets are one-click, not automatic, and local overrides need DevTools open. For a patch that has to be there every morning, a userscript manager or a small private extension is the honest answer.

Why can a script not see a variable that is clearly visible in the Console?

Because it is running in an isolated world. Chrome's documentation describes that world as a private execution environment that the page cannot reach, and the page's variables cannot be reached from it either. Reading a page global requires running in the main world.

Does injected code work on a page with a strict Content Security Policy?

An isolated world is not governed by the page's policy, but code running in the main world is, and a navigation to a javascript: URL is checked as inline script. Chrome's userScripts API provides a separate world that is exempt from the page's policy for exactly this reason.

Why does a patch stop working after clicking around inside the same app?

Because the app replaced the view without loading a new document, and the injection point only fires once per document. Watching for the element with a MutationObserver, and making the patch safe to run repeatedly, fixes it.

Is it safe to run a userscript found online?

It runs with the same authority as the logged-in session on every page it matches, so the script's match patterns and its network calls are the two things to read first. Anything that contacts a server the script does not need, or matches more sites than it claims to support, is worth rejecting on that basis alone.

Back to all posts