"Universal JavaScript" sounds simple until your helper touches document, process or fs. Here's a quick way to sort what's portable from what isn't, and a pattern for the bits that aren't.
The portable core
A surprising amount of the platform is the same in Bun and in a browser page. All of this runs unchanged in both:
const url = new URL("https://example.com/search?q=bun&page=2");
url.searchParams.get("page"); // "2"
const copy = structuredClone(new Map([["a", { n: 1 }]]));
copy.get("a"); // { n: 1 }
new TextEncoder().encode("héllo").length; // 6 bytes, 5 characters
new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(-1, "day"); // "yesterday"
crypto.randomUUID().length; // 36
URL, structuredClone, TextEncoder, Intl, crypto.randomUUID, fetch, setTimeout, AbortController: if your helper sticks to these, it's portable already.
What differs
Run these three lines and the answers depend on where you are:
"document" in globalThis;
"Bun" in globalThis;
globalThis.process?.versions?.bun ?? "not Bun";
In Bun: false, true, and a version string. In a browser page: true, false, "not Bun".
That's feature detection: ask for the thing you need, not for the name of the platform.
The pattern: detect once, at the edge
Keep the environment-specific bit in one small function and keep everything else portable:
function whereAmI(): string {
if (typeof document !== "undefined") {
return `a page titled "${document.title || "untitled"}"`;
}
const bun = (globalThis as any).process?.versions?.bun;
return bun ? `Bun ${bun}` : "somewhere else";
}
whereAmI();
typeof document is safe even where document doesn't exist, which is why it's the classic check. The rest of your module never needs to know.
Trying both without two setups
Checking this usually means a Node or Bun script and an HTML page. In JSLab, a JavaScript & TypeScript scratchpad for macOS, each tab picks its runtime from the status bar:
- Bun (the default): Bun and Node globals and the full Node API surface, no DOM.
- Browser: a real page, with no Node APIs at all.
-
Browser & Node APIs: a real page plus a Node compatibility layer (
fs/promises,child_process, afetchwith no CORS restriction).
Switching the runtime re-runs the tab, so you paste the snippet once, flip the menu, and read the new answers on the same lines.
Here are those three lines in a real JSLab tab, run twice. On top, in Bun: false, true, 1.4.0. Below, after switching the status-bar menu to Browser & Node APIs: true, false, not Bun. Line 7 reads a folder with node:fs/promises, which both of these runtimes allow.
Two things that only show up in the browser runtimes, and are worth knowing for real browser code too:
-
alert,confirmandpromptcan't block there:confirmalways returnsfalseandpromptreturnsnull. - The plain Browser runtime keeps real CORS, so a
fetchbehaves the way it would on a website.
Rule of thumb
Write against the web platform first, detect features at the edge, and test in both places before you call it universal.
JSLab launches Nov 15. The runtimes page: https://js-lab.app/docs/runtimes

Top comments (0)