The Boring Edges of Userscript Hot Reload

I made a small Node tool called userscript-hot-reload.

The idea is nothing new: serve the rebuilt userscript from localhost, have an installed loader fetch it, then eval the new version when the file changes.

That part worked quickly. However, the part that turned it into a working tool was the boring edge work around it: userscript metadata, strict CSPs, stale long-polls, refreshes, old dev servers, and old JS left on the page between reloads.

userscript-hot-reload demo

The happy path looks like this:

sequenceDiagram participant Editor participant Build as Existing build tool participant Dist as dist/*.user.js participant Server as Local dev server participant Loader as Installed loader participant Page as Live page Editor->>Build: save source Build->>Dist: write rebuilt userscript Server->>Dist: notice content hash changed Loader->>Server: long-poll with current hash Server-->>Loader: return new script body Loader->>Page: dispose old run Loader->>Page: eval new version

You run it from the userscript project:

npx userscript-hot-reload

It looks for built *.user.js files in the usual output folders, reads the userscript metadata block, and serves a loader for each script. That loader is the thing you install in Tampermonkey or Violentmonkey during development.

A Userscript That Loads the Userscript

The loader is a normal userscript, but it is not the real script. It is a small wrapper generated from the real script’s metadata.

That part matters because the loader needs to run in the same places as the script it is standing in for. So it copies the useful bits from the built file: @match, @include, @exclude, @grant, @connect, @require, @resource, @run-at, and @noframes.

Once installed, it does three things:

  • fetch the real built script from http://127.0.0.1:8765
  • execute it on the page
  • keep asking the dev server whether that exact version has changed

The update request is just a long poll:

GET /my-script.user.js?on-update&v=<current-content-hash>

The branch is intentionally plain:

flowchart LR A[Loader polls with current hash] --> B{Server has newer content?} B -- No --> C[204 No Content] C --> A B -- Yes --> D[Return new script body] D --> E[Run dispose callbacks] E --> F[eval new version] F --> A

There is no module graph and no clever runtime patching. The old run gets a chance to clean up, and then the new script runs again.

Versioning by Content

The server versions each built userscript by hashing its contents.

That means a rebuild that writes the same output does not reload the page code for no reason.

The watcher also watches directories, not individual files. That is to account for atomic writes, where the build tool writes a temporary file, then renames it over the old output.

The Single-Connection Surprise

The first long-poll version held each request open for minutes.

At a first glance, it makes sense: fewer requests, less noise etc., but the behavior was wrong in practice. In the privileged path, those requests are GM.xmlHttpRequest, not normal page fetches. With a few tabs open, I saw new pages sit there without the userscript UI. Triggering a hot reload made the missing run appear.

The dev-server logs made the shape clearer. In Tampermonkey on Chromium, every tab’s GM.xmlHttpRequest to the dev server appeared to go through one shared connection 🥲. Not the browser’s normal “a few requests per origin” pool.

So if Tab A was holding a long-poll, Tab B’s initial “give me the script” request waited behind it. Editing a file resolved the held poll early, freed the connection, and the queued tab finally loaded.

sequenceDiagram participant A as Tab A participant B as Tab B participant TM as Tampermonkey participant Server as Dev Server A->>TM: long-poll request TM->>Server: hold update request B->>TM: initial script fetch Note over B,TM: queued behind the held poll Server-->>TM: 204 timeout or changed script TM-->>A: poll finishes TM->>Server: fetch script for Tab B Server-->>TM: script body TM-->>B: script finally runs

So the current version does two things:

  • keep the long-poll hold short, currently 1000ms
  • abort the active request on pagehide, then stop the polling loop

The timeout is not just a timeout anymore. For this transport, it is also the maximum time another tab should have to wait for the shared connection. pagehide still matters for refreshes and closed tabs, but it does not solve the case where another tab is alive and legitimately polling. The short hold does.

Edits still reload immediately because a save resolves any waiting poll.

When localhost Is Not Just localhost

The first version of this in my head was just fetch() from the page to localhost.

That version is fast. It uses the browser’s normal connection pool, so it does not have the single-connection GM.xmlHttpRequest behavior above.

It also has two catches. A strict Content-Security-Policy can block page-context requests to localhost. And in Chrome, the first localhost request from a real page can trigger a local-network permission prompt: the site “wants to access other apps and services on this device.”

So the loader now treats transport as a development choice:

flowchart TD A([Generated loader]) --> B{useGmFetch?} %% Default — robust, survives strict CSP B -->|"default · true"| C["<div style='text-align:left'><b>GM.xmlHttpRequest</b><br>• works on strict-CSP pages<br>• shared manager transport<br>• short long-poll hold</div>"] %% Opt-out — faster but fragile B -->|"false · --fetch"| G["<div style='text-align:left'><b>Page fetch</b><br>• browser connection pool<br>• snappier across tabs<br>• may prompt / hit page CSP</div>"] classDef gm fill:#E8F5E9,stroke:#2E7D32,stroke-width:1.5px,color:#1B5E20; classDef page fill:#FFF3E0,stroke:#EF6C00,stroke-width:1.5px,color:#E65100; class C gm class G page

The default is GM.xmlHttpRequest. The generated loader adds the needed @grant GM.xmlHttpRequest and @connect localhost / @connect 127.0.0.1, then asks the userscript manager to make the request instead of the page. That can bypass the page’s CSP in the setups this tool targets, which makes it the safer default.

In tool fetch can be enabled by a parameter:

npx userscript-hot-reload --fetch

or set useGmFetch: false globally or per script.

Running Again Leaves Stuff Behind

Worth noting that it’s still JS running on a page. Re-executing a userscript does not magically undo whatever the previous run did.

If the script appended a panel, started an interval, added a listener, or created a MutationObserver, the next run needs to clean that up. The loader exposes a tiny hot context for that:

const hot = window.__USERSCRIPT_HOT_RELOAD_CURRENT__ ?? null;

const panel = document.createElement('div');
document.body.appendChild(panel);

const interval = setInterval(tick, 1000);

hot?.onDispose(() => {
  panel.remove();
  clearInterval(interval);
});

That is the part that makes this feel different from just re-fetching and evaluating a file. The reload loop is small, but the script still needs a way to be polite to its previous self.

There are older versions of this idea that use long-polling too, including Tampermonkey-Hot-Reload and this note on live reloading Tampermonkey scripts. There are also more complete build pipelines like vite-plugin-monkey and webpack-monkey if you’re ok shaping the whole project around userscript development rather than having a flexible drop-in.

built with SvelteKit. deployed on Azure Static Web Apps.