NW.js Mixed Context vs. Separate Context: Picking the Right Boundary for Node and the DOM
NW.js can run Node.js and the DOM in one JavaScript context or two. Here's what mixed vs. separate context actually changes for security, native addons, and migration — plus how to verify which mode your app runs in.
05 Oct 2026, 06:39 UTC

If you build a desktop app with NW.js, the first architectural decision you make — often without realizing it — is whether Node.js APIs live in the same JavaScript context as your page. That choice determines how easily you can call fs.readFileSync() from a button handler, and how much damage a single XSS bug can do. This post lays out what the two modes actually are, a concrete migration pattern, and how to verify which mode your app is really running in.
What "mixed context" actually means
NW.js runs Node.js and Chromium in one process. In the default mixed context mode, Node globals — require, process, Buffer, __dirname — are injected directly into the page's global scope. Your frontend code can do this with no IPC, no preload, no ceremony:
// index.html, mixed context mode
const fs = require('fs');
document.querySelector('#save').onclick = () => {
fs.writeFileSync('/tmp/out.txt', editor.value);
};That ergonomics win is real, and it's why many legacy NW.js apps exist. The cost is equally real: any script that executes in the page — a compromised CDN dependency, an injected <img> payload, a markdown renderer that missed sanitization — inherits full Node.js privileges. File system, child_process, native addons. In a browser, XSS steals cookies; in mixed-context NW.js, it steals the machine.
Separate context: the v0.13+ alternative
Since NW.js 0.13, you can run Node.js in a separate V8 context, isolated from the page's DOM context. The page no longer sees require or process; instead you bridge the two worlds explicitly, typically through a preload script that exposes a narrow, deliberate API:
// preload.js — runs with Node access, bridges to the page
const fs = require('fs');
global.saveFile = (path, contents) => {
if (!path.startsWith(allowedDir)) throw new Error('path rejected');
fs.writeFileSync(path, contents);
};The page then calls window.saveFile(...) and never touches fs itself. The attack surface shrinks from "all of Node" to "the three functions you chose to export." This mirrors Electron's contextIsolation + contextBridge model, with one big difference: Electron made isolation mandatory in v12, while NW.js still permits mixed context for backward compatibility. If you're porting an Electron security checklist to NW.js, that default is the first gap to close.
A pragmatic migration pattern
You rarely get to rewrite the whole app at once. A workable staged approach:
- Inventory Node usage in pages. Grep the renderer code for
require(,process., andnw.. Every hit is something that must move behind a bridge or into the Node context. - Define a minimal API surface. Replace direct calls with typed bridge functions (
readConfig(),saveFile()) rather than exposing rawfs. Validate arguments on the Node side. - Flip the manifest. Move new windows to separate context; keep legacy windows on mixed temporarily via per-window configuration rather than a global flag day.
- Audit native addons. A
.nodefile that was happilyrequire()d from a page now loads only on the Node side. Confirm each addon is built withnw-gypagainst your exact NW.js version — an ABI mismatch crashes at load, not at build.
How to verify which mode you're in
Don't trust the manifest; check the runtime. Open DevTools on a window and look at the Console's context dropdown (the "top" selector). Mixed mode shows a single context. Separate mode shows distinct entries for the page and Node contexts. A quick programmatic check from the page:
// Run in the page's DevTools console
const leaked = ['require', 'process', 'Buffer', '__dirname']
.filter(k => k in window);
console.log(leaked.length ? `MIXED context: ${leaked}` : 'separate context');Also check inside any iframes you embed — research notes that transparent/frameless windows in mixed mode can leak Node objects into child frames unless sandboxed. An empty leaked array in every frame is the pass condition.
The trade-off worth knowing
Separate context isn't free. Every cross-context call costs a small serialization overhead (on the order of a millisecond or two), so a chatty bridge — say, streaming file chunks one call at a time — will feel it. Batch transfers or move the loop to the Node side. Debugging also gets slightly harder: breakpoints and source maps must land in the correct context, and it's easy to stare at the Node context wondering why your DOM code isn't there. Finally, version pinning matters more than people expect: NW.js version numbers diverged from Chromium after 0.12, so always rebuild native modules with nw-gyp --target=<exact-nwjs-version> per release, and treat an auto-updater that swaps the NW.js binary as a native-module rebuild event.
Where to start
If you're writing new code, start in separate context with a preload bridge — it's the direction the NW.js docs recommend, and retrofitting isolation later is much harder than living with a small API surface from day one. If you're maintaining a mixed-context app, run the console check above today, then pick the single riskiest page (anything rendering remote or user content) and migrate that one first. Verify behavior per window with the DevTools context dropdown, and confirm your target version's flags against its changelog before shipping — context-isolation behavior has changed across releases.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.