Electron's Hidden Network Layer: Surfacing FFI Traffic in Chrome's DevTools
Source: lobsters
Electron’s split architecture means a single app can generate network traffic through at least three separate paths, and Chrome’s DevTools only has visibility into one of them. This creates a real gap when you need to debug a native library accessed via FFI, and a recent post on seg6.space documents one creative way to close it.
Three Network Paths in an Electron App
Electron wraps Chromium and Node.js in a single runtime, but those two components have independent network stacks. The renderer process uses Chromium’s networking layer, handled by the Net service and fully instrumented by the Chrome DevTools Protocol. The main process uses Node.js’s http/https modules, which are built on libuv and use OpenSSL directly, outside Chromium’s network stack entirely.
Then there is a third path: native code loaded via FFI.
When an Electron app loads a .dll, .so, or .dylib through ffi-napi or the newer koffi, any network calls that native library makes go through the operating system’s socket layer without touching either Chromium or Node.js. From Chrome DevTools’ perspective, these requests do not exist.
What the Network Tab Actually Sees
The Network tab works by consuming Chrome DevTools Protocol events from the Network domain. When you open DevTools, it calls Network.enable over a CDP WebSocket connection. The browser backend then fires events for every request the renderer makes:
Network.requestWillBeSent — request is about to go out
Network.responseReceived — response headers arrived
Network.loadingFinished — body fully received
Each request gets a unique requestId. When you click a row in the Network tab, DevTools calls Network.getResponseBody({ requestId }) to fetch the body on demand. Timing data (DNS, TCP, SSL, TTFB) comes from the timing object in the responseReceived event. None of this fires for requests made outside the Chromium network stack.
Electron exposes the webContents.debugger API, which lets the main process act as a CDP client directly:
const win = BrowserWindow.getAllWindows()[0]
win.webContents.debugger.attach('1.3')
win.webContents.debugger.sendCommand('Network.enable', {})
win.webContents.debugger.on('message', (event, method, params) => {
if (method === 'Network.requestWillBeSent') {
console.log(params.request.url)
}
})
This gives you programmatic access to the same event stream the Network tab consumes. That turns out to be a useful primitive for bridging external traffic sources into the DevTools UI.
The FFI Blind Spot
When a native library makes an HTTP request, it typically uses the platform’s TLS implementation directly: OpenSSL on Linux, SChannel on Windows, Secure Transport on macOS. The traffic is encrypted at the socket level and flows out without passing through any JavaScript runtime or Chromium instrumentation.
The standard proxy-based workaround has gaps at each layer. Electron’s session.setProxy() configures Chromium’s proxy for renderer traffic. Node.js’s http module respects https_proxy environment variables for main-process traffic. But native libraries do their own proxy configuration, or none at all, and their own certificate verification through the OS trust store or a bundled CA bundle. Getting a MITM proxy to intercept their traffic requires either configuring them explicitly, trusting a development CA they already know about, or hooking into the TLS layer from below.
Electron makes certificate verification easy to bypass for the renderer session:
session.defaultSession.setCertificateVerifyProc((request, callback) => {
callback(0) // 0 = success, bypasses verification
})
For native libraries that perform their own verification, you need FFI hooks into the verification function itself, a CA cert that the library is configured to trust, or a technique that captures traffic before TLS is applied. Certificate pinning makes the proxy approach even less reliable; the library may refuse connections from any CA it does not explicitly trust, regardless of what the OS trust store contains.
Routing Intercepted Traffic Into the Network Tab
Capturing the traffic is one problem; making it appear in Chrome’s Network tab is a separate engineering decision. The appeal of the Network tab is that it shows timing waterfalls, headers, status codes, and bodies in a searchable, filterable timeline. That interface is much more useful for debugging than tailing a structured log, especially when you want to correlate native library requests against renderer requests by timestamp.
CDP does not allow external clients to inject synthetic Network.* events. Only the browser backend emits them; clients only consume. The workaround is to route captured traffic back through the renderer process, which generates genuine CDP events. The approach the seg6.space post describes uses a local proxy as the bridge. The main process starts a proxy on localhost, intercepts FFI-level traffic via function hooks, and forwards it through the proxy. The renderer session is then configured to route through that proxy:
await session.defaultSession.setProxy({
proxyRules: 'http=127.0.0.1:8080;https=127.0.0.1:8080'
})
Requests forwarded through the proxy result in real fetches by the renderer, which generate the CDP events that the Network tab picks up. This introduces a second actual outbound request rather than replaying the original, but for debugging purposes the payload data is equivalent and the interface is the one developers already know.
The FFI-level interception captures the traffic by hooking into the TLS functions the native library calls. With pointer manipulation via ffi-napi or koffi, you can replace function implementations in the loaded library’s address space, intercepting SSL_read and SSL_write calls to capture plaintext payloads after decryption and before encryption. This bypasses certificate pinning entirely because you are operating below the TLS handshake; by the time SSL_read returns data, the certificate has already been accepted. The intercepted plaintext is what gets forwarded to the local proxy.
Alternative Approaches
The SSLKEYLOGFILE environment variable is the most universal option for TLS decryption. Chromium writes session keys when this variable is set, and Wireshark loads the keylog file to decrypt traffic captured with tcpdump or its own capture engine:
SSLKEYLOGFILE=/tmp/keys.log electron app.js
This covers renderer traffic and anything else in the Electron process that uses the same OpenSSL instance. The limitation is offline-only analysis: you capture a pcap file, load it alongside the keylog file, and inspect it in Wireshark. There is no live interactive view, and libraries that use system TLS (SChannel, Secure Transport) do not respond to this variable.
Frida is the most powerful option in this space. It injects a JavaScript runtime into the target process and can hook any function, including SSL_read and SSL_write at the libssl level, capturing plaintext from every source including native libraries. The setup overhead is significant: Frida requires a frida-server process, elevated permissions, and a custom script per inspection target. It is the right tool for reverse engineering an application you do not own; it is cumbersome for routine debugging of an application you are building.
Electron’s built-in session.webRequest API is the lowest-friction option for renderer traffic. It gives you URL, method, headers, and status codes in a Node.js event handler without any external tooling, but it does not expose response bodies:
session.defaultSession.webRequest.onCompleted((details) => {
console.log(details.statusCode, details.url)
})
The proxy-plus-CDP bridge approach sits in a useful middle ground: more initial setup than webRequest, less operational infrastructure than Frida, and it surfaces everything in an interface that needs no additional tooling to read.
When the Extra Complexity Pays Off
If you control the Electron app’s source code and the native library accepts standard proxy configuration, setting the proxy and disabling certificate verification in development usually gets you there without FFI hooks. The lower-level interception becomes necessary when the library performs certificate pinning, when it ignores system proxy settings, or when you want to place its requests alongside renderer requests in a single timeline without switching between tools.
There is a broader point here about webContents.debugger that is easy to overlook. Most Electron documentation treats it as a testing utility, but it is the correct primitive for any problem where you want CDP-level access from application code. The Network domain in particular is useful beyond debugging: enabling it from the main process and writing events to a structured log gives you a persistent record of everything the renderer touched, with full header and body data, at no external tooling cost. That the same channel can be used to wire in traffic from other sources, as the seg6.space post demonstrates, shows how much flexibility the CDP architecture leaves on the table for anyone willing to work with it directly.