Most Electron apps never touch native code: the renderer, the main process, and Node's built-in modules cover UI, networking, storage, and shell integration. The day comes when you need the other 1% — a fast image or audio routine, a vendor SDK that only ships a C ABI, a cryptographic or hardware feature Node can't reach from JavaScript. That is where native Node addons and FFI enter the picture, and where Electron stops behaving like a plain Node app. This guide covers the two ways to call native code, the ABI problem that trips up most first builds, how to load an addon safely from the main process, how to package .node binaries and their dependencies into an installer, and the pitfalls that only show up after you ship to another machine or architecture.

TL;DR

  • Pick the path by ownership: compile an N-API addon when you control the C/C++ source; use FFI (koffi/ffi-napi) to call a prebuilt C library you don't want to compile or maintain.
  • N-API is the stable ABI boundary. Prefer napi_* over the old V8/Node headers so a rebuild isn't forced by every Electron upgrade.
  • Electron has its own Node ABI. An addon built against your system Node headers will not load in Electron; rebuild against Electron's headers with electron-rebuild or prebuilds matched to the Electron version, platform, and architecture.
  • Load native modules in main or preload, never in the renderer. The renderer has no Node access under contextIsolation/sandbox, and a leaked native handle there is a security and stability liability.
  • .node binaries and their shared-library dependencies usually must be unpacked from the asar. Mark them in asarUnpack / extraResources, and make sure code signing and notarization cover the native files too.
  • The two traps that cost real time: (1) rebuilding against the wrong headers so the addon loads in dev but crashes in the packaged app; (2) forgetting a second architecture (arm64 vs x64) or a runtime .dll/.so dependency that works on your machine but is missing on the user's.

Table of contents

  1. When Electron needs native code
  2. Two paths: compiled addon vs FFI
  3. The ABI problem: why an addon built for Node won't load in Electron
  4. Setting up an N-API addon
  5. Loading the addon safely in the main process
  6. The FFI path: calling a C library without compiling
  7. Packaging binaries and cross-architecture builds
  8. Upgrades, rebuilds, and the CI matrix
  9. Common pitfalls
  10. Checklist

When Electron needs native code

JavaScript in Node is usually enough, but a few problems genuinely sit outside it:

  • CPU-heavy routines — image resampling, audio DSP, parsing a large binary format, hashing or compression where the JS overhead dominates.
  • Vendor SDKs — a hardware, printer, scanner, or finance SDK that ships headers and a shared library but no JavaScript binding.
  • OS features Node doesn't expose — a specific Win32 or Cocoa API, a device driver interface, or a system service that needs a native call.
  • Reusing existing C/C++ — a mature library you don't want to rewrite or maintain a JS port of.

The cost of going native is real: you now own a compiled binary, a build toolchain, ABI compatibility, per-architecture artifacts, and signing obligations. Reach for it when the JS alternative is measurably too slow or unavailable, not as a default.

Two paths: compiled addon vs FFI

Concern Compiled N-API addon FFI (koffi / ffi-napi)
How it runs A .node binary compiled against Node/Electron headers Loads a prebuilt .dll/.so/.dylib at runtime, marshals calls from JS
Best when You own or can build the C/C++ source; you want a typed, maintained binding You call a third-party C library you won't compile; prototyping a system API
Toolchain node-gyp, a C++ compiler, Python, headers for the target runtime No compiler at build time; the library's ABI must be declared in JS
Type safety Checked at compile time through the N-API layer Declared by hand in JS; a wrong signature corrupts memory at runtime
Maintenance Rebuild per ABI/platform/arch; source under your control Library updates can change symbols/signatures; you track them manually
Performance Closest to native; one call across the boundary Marshaling overhead per call, but avoids a build step

A practical rule of thumb: write an addon for code you maintain; use FFI for libraries you consume.

The ABI problem: why an addon built for Node won't load in Electron

A native addon is a compiled binary that links against the V8 and Node symbols of the runtime it was built for. Electron ships its own bundled Node and V8, and those have their own ABI version. If you compile an addon against the headers of the system node on your machine, then require it inside Electron, you typically get an error like:

Error: The module '...\my-addon.node' was compiled against a different Node.js version using NODE_MODULE_VERSION 115. This version of Node.js requires NODE_MODULE_VERSION 121. 

The fix is to build the addon against Electron's headers, not system Node. Two pieces make this work:

  • N-API (Node-API) — a stable ABI layer (napi_* functions) that isolates your code from V8 internals. A binary built against one N-API version keeps working across later Node/Electron upgrades without recompiling for V8 changes.
  • ABI version alignment — NODE_MODULE_VERSION must match Electron's bundled Node. electron-rebuild downloads the matching Electron headers and rebuilds the addon against them; prebuilds are matched to Electron version, platform, and architecture instead of the system Node version.

Check the ABI Electron expects on the machine you build on:

npx electron --version node -e "console.log(process.versions.modules)" 

The addon's NODE_MODULE_VERSION has to line up with the Electron build you package, not the node you used to run scripts.

Setting up an N-API addon

A minimal addon lives beside the Electron app's source and builds with node-gyp. The build description:

# binding.gyp { "targets": [ { "target_name": "my_addon", "sources": ["src/my_addon.cc"], "cflags!": ["-fno-exceptions"], "cflags_cc!": ["-fno-exceptions"], "include_dirs": ["<!@(node -p \"require('node-addon-api').include\")"] } ] } 

The C++ side uses the N-API surface and exposes one function:

// src/my_addon.cc #include <napi.h> Napi::Value HashString(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (info.Length() < 1 || !info[0].IsString()) { Napi::TypeError::New(env, "Expected a string").ThrowAsJavaScriptException(); return env.Null(); } std::string input = info[0].As<Napi::String>().Utf8Value(); // Native work happens here: e.g. a fast transform over `input`. std::string result = "native:" + input; return Napi::String::New(env, result); } Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("hashString", Napi::Function::New(env, HashString)); return exports; } NODE_API_MODULE(my_addon, Init) 

package.json keeps the rebuild step explicit:

{ "scripts": { "build": "node-gyp rebuild", "rebuild:electron": "electron-rebuild -f -w my-addon", "prebuild": "prebuildify --napi --target 12.0.0 --arch x64,arm64" }, "dependencies": { "node-addon-api": "^8.0.0" }, "devDependencies": { "electron-rebuild": "^3.2.0", "node-gyp": "^10.0.0" } } 

Using N-API (NODE_API_MODULE, napi_* / node-addon-api) means your binding code talks to a stable interface rather than V8 internals, which is what lets one prebuild survive minor Electron bumps.

Loading the addon safely in the main process

Native code belongs in the privileged side of the app. Under a hardened renderer (contextIsolation: true, sandbox: true, nodeIntegration: false), the renderer has no require and no access to Node symbols, so trying to load an addon there fails by design. Keep the binding in main and expose a narrow, validated method through the preload bridge.

// main/native-bridge.ts import path from "node:path"; import { app } from "electron"; let addon: { hashString(input: string): string } | null = null; function loadAddon() { if (addon) return addon; // In a packaged app the .node lives under resources, often unpacked from asar. const addonPath = app.isPackaged ? path.join(process.resourcesPath, "native", "my_addon.node") : path.join(__dirname, "..", "build", "Release", "my_addon.node"); try { // eslint-disable-next-line @typescript-eslint/no-var-requires addon = require(addonPath); } catch (err) { // Never crash the whole app on a missing/unsupported binary; degrade gracefully. console.error("Native addon failed to load:", err); addon = null; } return addon; } export function hashStringSafe(input: string): string { if (typeof input !== "string" || input.length === 0) { throw new Error("hashStringSafe: input must be a non-empty string"); } const mod = loadAddon(); if (!mod) return `js-fallback:${input}`; return mod.hashString(input); } 

The preload then exposes only hashStringSafe, not the raw addon object:

// preload/bridge.ts import { contextBridge, ipcRenderer } from "electron"; contextBridge.exposeInMainWorld("nativeApi", { hash: (input: string) => ipcRenderer.invoke("native:hash", input), }); 

Main validates the incoming payload before it ever reaches the native function. A native crash takes down the process that loaded it — keeping that process in main, behind a checked IPC boundary, bounds the damage.

The FFI path: calling a C library without compiling

When the native code already exists as a shared library and you don't want to own a build, FFI loads it at runtime and declares the C signature in JavaScript. Modern options like koffi are the lighter, better-maintained choice over the older ffi-napi, which depends on an older ABI and is often painful to rebuild for new Electron versions.

// main/ffi-client.ts import koffi from "koffi"; import path from "node:path"; // Resolve the shipped library per platform. const libName = process.platform === "win32" ? "vendor/libcrypto.dll" : process.platform === "darwin" ? "vendor/libcrypto.dylib" : "vendor/libcrypto.so"; const lib = koffi.load(path.join(process.resourcesPath, libName)); // Declare the C signature that matches the real header. const EVP_MD_size = lib.func("int EVP_MD_size(pointer md)"); const someTransform = lib.func( "void some_transform(const char* in, char* out, int len)" ); export function transform(input: string): string { const out = Buffer.alloc(input.length + 1); someTransform(input, out, input.length); return out.toString("utf8").replace(/\0+$/, ""); } 

FFI trades a compile step for careful signature work: types, pointer lifetimes, and string encodings are declared by hand, and a wrong declaration corrupts memory at call time. Treat FFI declarations like you would a foreign header — review them, keep them narrow, and verify buffers are sized to the library's contract.

Packaging binaries and cross-architecture builds

The packaged installer is where most "it works on my machine" native failures surface.

asar unpacking

Compiled .node files and their runtime shared libraries often cannot stay inside the asar archive: they are loaded by the OS loader or dynamic linker, which expects a real filesystem path. Mark them for unpack so the packager copies them to an unpacked directory alongside the archive.

# electron-builder.yml asarUnpack: - "**/*.node" - "resources/vendor/**/*.dll" - "resources/vendor/**/*.so" - "resources/vendor/**/*.dylib" extraResources: - from: "vendor/${arch}" to: "vendor" 

Dependencies travel with the binary

A .node or FFI library can link against another shared library that isn't on the user's machine. Bundle those runtime dependencies into extraResources and resolve them relative to process.resourcesPath, rather than assuming a system install. On Windows, check the DLL search order; on macOS and Linux, verify the install/rpath resolves to your bundled copy.

Architectures and universal builds

Build per target, don't assume one binary covers everything:

Target Notes
Windows x64 Most common; ship the .node and any .dll dependencies together
macOS x64 Intel Macs; match the arch in the prebuild and electron-builder target
macOS arm64 Apple Silicon; a Rosetta x64 binary behaves differently from a native arm64 one
Linux x64 / arm64 glibc version matters; a build on one distro may not load on another

On macOS, a universal binary contains both arches, and code signing plus notarization must cover the bundled native files — a missing signature on a .dylib or .node can block launch just as easily as the app bundle itself.

Upgrades, rebuilds, and the CI matrix

Electron upgrades often move the bundled Node ABI. When that happens, addons compiled against the previous ABI stop loading until rebuilt.

  • Local dev: run electron-rebuild after installing dependencies or after an Electron bump. It targets Electron's headers directly.
  • Prebuilds: commit or cache prebuilt binaries keyed by Electron version, platform, and arch so CI and users don't compile on install.
  • CI matrix: build the addon (and FFI library package) for each platform/arch you publish, against the Electron version you ship. Don't compile on Windows and reuse the artifact on macOS.
  • Verify in the packaged app, not only in dev: a dev run loads from build/Release, while the packaged app loads from resources; a path or asar issue only appears in the installer build.

Keep an eye on which prebuild matches your current Electron: an outdated prebuild can be the difference between a working packaged app and a NODE_MODULE_VERSION crash on first launch.

Common pitfalls

Pitfall What happens Fix
Built against system Node, not Electron Addon loads in terminal Node, crashes on require in Electron Rebuild against Electron headers with electron-rebuild or matched prebuilds
Used V8/Node internal headers instead of N-API Every Electron upgrade forces a painful rebuild Stick to the napi_* / N-API stable layer
Loaded the addon in the renderer No Node access under sandbox; security boundary broken Keep the binding in main; expose validated methods via preload bridge
Left .node inside the asar Loader can't map it from the archive; packaged app fails to start Add the binary and its deps to asarUnpack / extraResources
Missing a bundled shared-library dependency Works on your machine, missing .dll/.so/.dylib for the user Bundle runtime deps and resolve from resourcesPath
Built x64 only for arm64 hardware Apple Silicon / ARM users hit an ABI or arch mismatch Build per arch; produce a universal macOS binary when needed
FFI signature declared wrong Memory corruption or garbage values at call time Mirror the real header; size buffers correctly; review pointer lifetimes
Native crash in main takes down the app One bad input kills the whole window session Validate before the native call; degrade to a JS fallback; isolate heavy native work in a utility process when it can crash
Signed the app but not the bundled native files macOS Gatekeeper blocks launch on the extra binaries Include bundled .node/.dylib in signing and notarization

Checklist

  • [ ] The need for native code is justified by a measured JS bottleneck or an unavailable vendor SDK.
  • [ ] Path chosen deliberately: N-API addon for owned source, FFI for a consumed C library.
  • [ ] The addon uses N-API stable interfaces, not raw V8/Node internals.
  • [ ] Binaries are built against Electron's headers/ABI, verified against the shipped Electron version.
  • [ ] The addon loads only in main or preload; the renderer reaches it through a validated IPC/bridge method.
  • [ ] Inputs are validated before reaching the native function; a load failure degrades to a fallback instead of crashing the app.
  • [ ] .node binaries and runtime shared libraries are unpacked from asar and bundled via extraResources.
  • [ ] Artifacts exist for every published platform and architecture (Windows x64, macOS x64/arm64, Linux as applicable).
  • [ ] Bundled native files are included in macOS signing and notarization.
  • [ ] CI rebuilds addons per Electron version and verifies the packaged installer, not only the dev build.