NW.js's Two JavaScript Contexts: The Architecture Decision That Makes or Breaks Your Desktop App
NW.js runs two JavaScript contexts — Node and window — and the node-remote manifest field decides which pages get full OS access. Here's how to scope it tightly and bridge the two safely.
26 Mar 2026, 13:27 UTC

You picked NW.js because a web page that can call require('fs') is a compelling shortcut: your existing web UI becomes a desktop app without learning a new UI toolkit. The first surprise comes when require works in one file and throws in another, or when a library you load from a CDN silently gains the ability to delete the user's home directory. Both symptoms trace back to the same thing: NW.js runs two distinct JavaScript contexts, and which one your code lands in is an architecture decision, not an accident.
The thesis of this piece: treat the Node context as privileged backend code and the window context as untrusted UI, then connect them with the narrowest bridge you can write. NW.js gives you the rope to do the opposite; the node-remote manifest field is where you decide how much rope to hand out.
What the two contexts actually are
NW.js merges Chromium and Node.js into one runtime. That merge creates two environments:
- The Node context has the full Node API surface —
fs,os,child_process, native modules — but no DOM. There is nowindow, nodocument. - The window context is the normal browser page environment: DOM,
window, fetch, and — optionally — Node APIs mixed in.
The "optionally" is the dangerous part. When Node is enabled in a window, the page's global scope and Node's globals coexist. That means real risks beyond the obvious security ones: naming collisions (a page script defining something Node internals expect), prototype pollution affecting both worlds, and libraries that feature-detect require or process and suddenly behave like server code inside your UI.
node-remote: the actual decision point
The node-remote field in your app's package.json manifest controls which origins are allowed to access Node APIs from a window. The engineering mistake is leaving it broad — for example, a wildcard pattern — because then any content that gets loaded, including a remote page in an iframe or a third-party script, inherits full OS access. In that configuration, a cross-site scripting bug stops being a UI annoyance and becomes remote code execution on the user's machine.
The safer pattern is the inverse: default to no Node in windows that display anything you didn't write, and whitelist only your own local pages — or better, keep even those mostly Node-free and route OS operations through a small bridge.
A worked example: a notes app with a narrow bridge
Suppose you're building a Markdown notes app. The UI is plain HTML/JS; the only OS operations it needs are "list files in a folder," "read a file," and "write a file." A reasonable layout:
my-notes/
package.json
index.html # UI, no direct fs usage
js/ui.js # DOM logic, calls window.notesBridge
node/main.js # Node context: implements the bridge
The manifest keeps Node out of remote content by not whitelisting anything beyond your local app:
{
"name": "my-notes",
"main": "index.html",
"node-remote": "http://localhost/*"
}The exact matching rules for node-remote have changed across NW.js releases, so check the manifest documentation for the version you actually ship — don't copy a pattern from an old tutorial and assume it still means the same thing.
In the Node context script, expose exactly three operations and nothing else:
// node/main.js (Node context — no DOM here)
const fs = require('fs');
const path = require('path');
const NOTES_DIR = path.join(process.env.HOME || '.', 'my-notes-data');
function safePath(name) {
const p = path.resolve(NOTES_DIR, name);
if (!p.startsWith(path.resolve(NOTES_DIR) + path.sep)) {
throw new Error('path outside notes directory');
}
return p;
}
global.notesBridge = {
list: () => fs.readdirSync(NOTES_DIR).filter(f => f.endsWith('.md')),
read: (name) => fs.readFileSync(safePath(name), 'utf8'),
write: (name, text) => fs.writeFileSync(safePath(name), text, 'utf8')
};The UI then calls notesBridge.read('todo.md') instead of touching fs itself. If the UI is ever compromised — say, a malicious note rendered without sanitization — the attacker's reach is three functions constrained to one directory, not the whole filesystem.
How to check your context boundaries actually hold
This is verifiable in about ten minutes with the sdk flavor of NW.js (which bundles DevTools — fine for development, but ship the "normal" flavor to users):
- Create a minimal app with
node-remoteunset. From the page's DevTools console, runtypeof require('fs')and note whether it succeeds. - Load a remote URL in an iframe and run the same check inside that frame's console.
- Set
node-remoteto a tight pattern, restart, and re-run both checks. The remote frame should lose Node access; if it doesn't, your pattern is broader than you think. - Before shipping, compare your NW.js version's bundled Chromium and Node versions against current security advisories — NW.js can lag upstream Chromium fixes.
The trade-off you're accepting
NW.js's convenience has a price beyond security discipline: distribution size. Packaging means shipping the whole NW.js runtime per platform alongside your app (typically as an app.nw zip next to the binary), so even a trivial app starts at tens of megabytes. You're also tied to NW.js's release cadence for Chromium and Node security fixes, which doesn't always track upstream closely. If your app needs only occasional OS access and you're sensitive to binary size or patch latency, a shell that reuses a system browser or a thinner native bridge may be the better call.
Where to start Monday morning
Audit your current manifest: if node-remote is a wildcard or absent while remote content is loadable anywhere in your app, that's the first thing to fix. Then grep your UI code for direct require('fs') calls and ask whether each one could move behind a three-function bridge. The dual-context model is NW.js's signature strength — but only if you decide, deliberately, which side of the line each file lives on.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.