Troubleshooting
Ordered roughly by how often each one is the answer.
Start here
Three checks in the browser console. They rule out most problems in ten seconds.
crossOriginIsolated;
navigator.serviceWorker.controller?.scriptURL;
performance.getEntriesByType("resource").filter(r => r.name.includes("wasm"));
For Scramjet, the first value must be true, the second should name your
sw.js, and the third should include the
rewriter WebAssembly file.
The page loads but nothing happens when I navigate
If it worked a minute ago and the frame now shows Cannot GET /~/sj/..., skip
to
the idle service worker bug.
crossOriginIsolated === false. possibly, but check the service worker
first. Scramjet runs without isolation; what isolation buys you is proxied
sites being able to use SharedArrayBuffer themselves. If nothing at all
happens on navigation, a service worker scope problem is more likely. Still,
these are the headers you want, and it costs one line to rule out:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Check the document response in the Network tab, not just assets. Then check
you are on https:// or http://localhost, a LAN address like
http://192.168.1.5:8080 is not a secure context and cannot isolate.
If the headers are set but isolation is still false, something between you and
the browser is stripping them: Cloudflare, an nginx proxy_pass, or a hosting
layer.
No service worker controller. If
navigator.serviceWorker.controller is undefined, the worker has not claimed
the page. Reload once. If it stays undefined, look for registration errors and
confirm sw.js is served from the root of your origin. A worker at
/js/sw.js can never control /~/sj/….
A JavaScript error before the listener attached. If your address bar does a
full page navigation instead of loading the iframe, your submit handler never
ran because the module threw. Check the console, and try importing it directly:
import("/js/app.js").then(() => "ok").catch(e => e.message);
Cannot GET /~/sj/... after the tab has been sitting idle
An upstream bug that affects every transport. On Wisp it is a papercut; on Bare it is worse. Either way it is worth recognising, because the error text points at your server rather than at the cause.
The symptom: your proxy works, you leave the tab alone for a minute or two, then navigate again, or press Back, and the frame shows your server's 404 page.
Cannot GET /~/sj/<controller>/<frame>/https%3A%2F%2F...
That text is your own web server answering. It means the service worker did not intercept the request at all, so it fell through to the network and hit Express or Fastify, which has no such route.
Why
Browsers terminate idle service workers, usually after about thirty seconds of
no events. Scramjet's worker keeps its list of routable frame prefixes in module
scope, so when the browser restarts it that list is empty and shouldRoute()
returns false for every proxied URL.
Upstream anticipates this. On activation the worker posts a
$controller$swrevive message to every client, and the controller is supposed
to re-register its prefix in response. In scramjet-controller 0.0.14 that
handshake does not fire in time for the navigation that woke the worker, so the
first one after an idle period is lost.
Reproducing it
- Load your proxy and navigate to any site. It works.
- Leave it alone for about 75 seconds.
- Navigate again.
Confirmed on both libcurl over Wisp and bare-transport, so it is not
transport-specific. A busy page can mask it, because any request through the
worker resets the idle timer; a page that goes quiet will hit it.
How bad it is, and how to recover
Measured on a generated project, twice each:
| After the worker has died | Result |
|---|---|
| Navigate somewhere new | still 404 |
| Reload the frame | recovers |
| Reload the whole shell page | recovers |
Reloading the frame is the fix, and on Wisp that makes this a papercut rather than a real problem: the reload button in your own browser controls clears it, and normal use rarely goes quiet long enough to trigger it in the first place. The usual way to see it at all is pressing Back after a pause.
On the Bare transport it is worse. There, a full page reload is needed, so a user who hits it cannot recover from your in-page controls.
Generated projects already handle this. The engine adapter pings the worker every 15 seconds, which keeps it from going idle in the first place:
const keepAliveIntervalMs = 15000;
const startKeepAlive = () => {
setInterval(() => {
navigator.serviceWorker.controller?.postMessage("keepalive");
}, keepAliveIntervalMs);
};
The payload is a plain string on purpose. Both of the worker's message
listeners bail on typeof e.data != "object" before doing anything, so the ping
wakes the worker and touches no logic.
Measured against a build with no keepalive, a 40-second idle already breaks, so anything at or above 30 seconds is too slow to help. With the 15-second ping, the same project survives a 90-second idle and navigates normally.
If you are writing your own client rather than generating one, copy this. Do not set it to something plausible-sounding like 70 seconds, which fires long after the worker is already gone and protects nothing.
The alternative, reloading the frame when a proxied navigation lands on your own 404, is deliberately not what generated projects do. It works, but it hides every other cause of a 404 behind an automatic reload, which turns a clear failure into a mystery reload loop. Keep your 404 visible and keep the worker alive instead.
SharedArrayBuffer is not defined
Cross-origin isolation. See above.
Sites load but subresources 404 or are blocked
Under require-corp, cross-origin assets must opt in with
Cross-Origin-Resource-Policy or CORS. Google Fonts, CDN scripts, and external
favicons in your own shell will be blocked.
Self-host them, or try Cross-Origin-Embedder-Policy: credentialless, which
still grants isolation but does not require opt-in. Chromium 96+ and Firefox
119+ support it. Safari does not, at any version, and WebKit has not
signalled that it intends to, so credentialless is not a fix you can ship to
everyone. Self-hosting is the portable answer.
This applies to your shell's assets. Assets of proxied pages go through the service worker and are same-origin by the time the browser sees them.
environment detection error on server start
You imported a browser-only transport in Node:
import { libcurlPath } from "@mercuryworkshop/libcurl-transport";
That exact string comes from libcurl's Emscripten runtime, which is built for
worker,web only and throws when it finds neither. Epoxy 3.x is Rust and
wasm-bindgen, so it fails differently, but for the same reason and with the same
fix.
Neither libcurl 2.x nor epoxy 3.x exposes a Node entry point any more. Resolve the path without executing the module:
const require = createRequire(import.meta.url);
const dir = path.dirname(require.resolve("@mercuryworkshop/libcurl-transport"));
See Breaking changes.
BareMux is not defined
/baremux/index.js did not load, or loaded after your script. It is a classic
script, not a module, so order in your HTML matters:
<script src="/baremux/index.js"></script>
<script type="module" src="/js/app.js"></script>
Also confirm the server mounts it:
import { baremuxPath } from "@mercuryworkshop/bare-mux/node";
app.use("/baremux/", express.static(baremuxPath));
Requests hang with no error (bare-mux)
The transport probably failed to construct inside the SharedWorker, and SharedWorker errors do not appear in the page console.
Open chrome://inspect/#workers (or Firefox's about:debugging) and inspect
the worker directly. It is the best bare-mux debugging trick there is, and
almost nobody knows it.
The wisp connection fails or drops
Check the path matches exactly. A very common bug:
A proxied URL can contain /wisp/ in its path, so do not use
req.url.includes("/wisp/"). Match the parsed pathname:
if (new URL(req.url, `http://${req.headers.host}`).pathname === "/wisp/") {
wisp.routeRequest(req, socket, head);
}
The base only exists to make req.url parseable; the scheme and host are
discarded when you read .pathname.
Check the scheme. An https:// page cannot open a ws:// socket. The
browser blocks it as mixed content.
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
Check reverse-proxy timeouts. nginx's default proxy_read_timeout is 60
seconds, which kills idle wisp connections
mid-session:
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
And confirm the upgrade headers are forwarded, see Deployment.
A specific site is broken
In this order:
- Try another transport. libcurl and epoxy use different HTTP/TLS implementations, and bare does not terminate TLS in the browser at all, so a site can work on one and not another. Shipping more than one transport turns on transport switching and gives users that fallback themselves.
- Try Scramjet if you are on Ultraviolet. UV's JavaScript rewriter breaks
on more sites, and it has not had a release since October 2024, so most of
those breakages are not getting fixed for you. Check UV's
mainbranch before giving up though, since a few fixes landed there after the last npm release. See breaking changes. - Check whether the site needs WebSockets. If you are on a Bare deployment, they will not work at all.
- Check the console inside the frame. Select the iframe's context in the devtools context dropdown. Rewriter failures usually show up as a syntax error in a rewritten script.
- Turn on
rewriterLogsand reload. See below.
Getting the rewriter to tell you what it is doing
rewriterLogs is off by default and is the single most useful flag when you
suspect the rewriter rather than your own code. Scope it to the failing site so
you are not drowning in output from every request:
scramjetConfig: {
siteFlags: {
"https://discord\\.com/.*": { rewriterLogs: true }
}
}
Two kinds of output land in the frame's console:
Parse errors. Every error oxc produced while parsing a script is printed as
oxc parse error. Without the flag these are swallowed, because
allowInvalidJs passes the original script through instead, which is exactly
why a site can be quietly half-broken with a clean-looking console. Pair the
flag with allowInvalidJs: false to make those failures loud.
Timing. Each rewrite prints how long it took, bucketed:
[time] oxc rewrite for "https://discord.com/assets/app.js" was decent speed (23.45ms)
The buckets are BLAZINGLY FAST under 1 ms, decent speed under 500 ms, and
really slow above that. HTML rewriting and rewriter-pool allocation report the
same way. Consistent really slow on one site is the signal to reach for
disableComputedWrap through siteFlags. See
the flags that matter.
Some sites will not work. Heavy anti-bot protection, aggressive integrity checking, and DRM video are the usual categories, and no amount of configuration changes that.
Site compatibility works through the categories in order, so you can tell which one you are hitting before spending time on it.
Sites become /undefined
This affects any site, not one search engine. Any page that calls
history.pushState or history.replaceState with fewer than three arguments
can hit it, and single-page apps do that constantly.
Omitting the URL argument is valid: it keeps the current URL. Scramjet
2.0.67-alpha.2 turns it into a navigation to /undefined. The cause is one
line, and you can read it in the published bundle's own sourcemap:
const url = String(ctx.args[2]);
if (url || url === "") ctx.args[2] = relevantclient.rewriteUrl(url);
String(undefined) is the string "undefined", which is truthy, so the guard
on the second line passes and a missing argument gets rewritten into a real
path. The result is a proxied request to https://crllect.dev/undefined.
Upstream has already fixed this, by only stringifying when the argument is actually present:
const url = ctx.args[2] ? String(ctx.args[2]) : undefined;
That is on main and is not in any published release, so it does not help you
yet. Check whether it has shipped before carrying the workaround forward.
Until then, the generated Scramjet adapter installs a frame-local compatibility plugin. It supplies the frame's current URL only when either History method omits the URL. It leaves real URLs, the URL watcher, and HTTP caching unchanged:
for (const method of ["pushState", "replaceState"]) {
const original = history[method];
history[method] = function (data, unused, url) {
return original.call(this, data, unused, url ?? client.url.href);
};
}
Do not filter UrlWatcherPlugin values or override frame.back() to work
around this. Both affect normal navigation for every site. Upgrade or regenerate
if a generated project lacks the compatibility plugin.
Everything works locally, breaks in production
Almost always one of:
| Cause | Check |
|---|---|
| No HTTPS | Service workers need a secure context |
| Headers stripped by a proxy/CDN | crossOriginIsolated in the production console |
| Websockets not forwarded | Test a site that needs them |
| Reverse-proxy timeout too low | Connection drops after ~60s |
Stale sw.js cached | Serve it with Cache-Control: no-cache |
| Host blocks outbound sockets | Wisp cannot connect anywhere |
Changes to sw.js do not take effect
Service workers update on their own schedule. Force it:
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", event =>
event.waitUntil(self.clients.claim())
);
And never cache it:
res.setHeader("Cache-Control", "no-cache");
While developing, tick Update on reload in the Application → Service Workers panel.
Tabs reload every time I switch
You are unmounting the iframe. Toggle display instead of removing the element
from the DOM. Removing it destroys the document. See
Multiple tabs.
The address bar keeps clearing while I type
You are rewriting input.value on every render. Guard it:
if (!addressBarFocused) {
addressBar.value = tab?.url ? formatForDisplay(tab.url) : "";
}
Transport switching does nothing
On bootstrap wiring it cannot work. proxy-bootstrap fixes the transport at
server start and only serves that one client. Regenerate with manual wiring.
Otherwise: setTransport affects the next request. Pages already loaded
keep their connections until reloaded.
Searching for a URL I pasted
Your input parser misclassified it. The three-line version everyone copies misclassifies a lot. See URL parsing and history, this one has a privacy cost, because misclassified URLs get sent to a search engine.
Still stuck
Collect these before asking anywhere:
crossOriginIsolatednavigator.serviceWorker.controller?.scriptURL- Engine and exact versions from
package.json - Which transport
- Whether it works on
localhost - The first error in the console, and the first in the frame's console
- The Network tab entry for the failing request
"It doesn't work" is unanswerable. That list usually contains the answer.
Source: docs/reference/troubleshooting.md
Verified against Scramjet 2.0.67-alpha.2, controller 0.0.14, and Ultraviolet undefined on 2026-08-02. If this page and upstream disagree, upstream is right.