Running Node.js for Raspberry Pi projects bridges the gap between high-level asynchronous JavaScript and low-level hardware control. While Python dominates the Pi ecosystem, Node.js offers a massive advantage for makers building networked IoT devices: non-blocking I/O. You can spin up an Express web server, listen for MQTT broker messages, and toggle GPIO pins all within the same event loop without managing complex threading.

However, controlling physical pins via JavaScript introduces specific hardware and OS-level pitfalls. This guide walks through a complete 12V DC fan control build, provides production-ready code, and debugs the exact error strings that trip up most developers—especially following the architectural shifts in recent Pi hardware.

Project Spec Sheet & Parts List

Difficulty Rating: Intermediate
Estimated Time: 45 minutes
Target Board Variant: Raspberry Pi 4 Model B (4GB or 8GB RAM). Note: The code and sysfs debugging steps specifically target the Pi 4 due to the Pi 5's deprecation of the sysfs GPIO interface.

To demonstrate reliable hardware switching, we are bypassing basic LEDs and driving a 12V DC cooling fan using a logic-level N-Channel MOSFET. This teaches proper isolation and current handling, which is critical when moving from 3.3V logic to higher-voltage loads.

Required Components

Component Specification / Part Number Estimated Cost
Microcontroller Raspberry Pi 4 Model B (4GB) $55.00
Switching Component IRLZ44N N-Channel Logic-Level MOSFET $1.50
Load 12V DC PC Cooling Fan (4-pin or 2-pin) $8.00
Gate Resistor 220Ω 1/4W Carbon Film $0.10
Pull-down Resistor 10kΩ 1/4W Carbon Film $0.10
Flyback Diode 1N4007 Rectifier Diode $0.20
Power Supply 12V 2A DC Wall Adapter (for the fan) $9.00

Wiring the Hardware: Pin Mapping & Connections

The Raspberry Pi's GPIO pins output 3.3V logic and can safely source only about 16mA per pin. The IRLZ44N MOSFET requires very little gate current to switch, but its internal gate capacitance can cause a brief inrush current when charging. The 220Ω gate resistor protects the Pi's GPIO pin from this spike.

Pin Mapping Table

Raspberry Pi 4 Pin BCM GPIO Number Wiring Destination
Pin 12 GPIO 18 (Hardware PWM0) 220Ω Resistor → MOSFET Gate
Pin 14 GND MOSFET Source & 10kΩ Pull-down
Pin 17 3.3V Power (Unused in this build, reserved)

Circuit Path:

  • Connect the 10kΩ pull-down resistor between the MOSFET Gate and Source. This ensures the fan stays off while the Pi is booting and GPIO 18 is in a high-impedance state.
  • Connect the 12V fan's positive wire to the 12V power supply.
  • Connect the fan's negative wire to the MOSFET Drain.
  • Place the 1N4007 flyback diode across the fan terminals (cathode/stripe pointing toward the 12V positive side) to suppress inductive voltage spikes when the fan spins down.
⚠️ Safety Callout: Never connect the 12V power supply ground directly to the Pi's 3.3V or 5V rails. The 12V PSU ground must only share a common ground with the Pi's GND pin to complete the MOSFET gate control circuit.

Writing the Node.js GPIO Control Code

We will use the onoff library, which is the standard for Node.js GPIO manipulation on the Pi 4. It interfaces directly with the Linux sysfs GPIO subsystem.

Prerequisite: Ensure Node.js (v18 or v20 LTS) is installed on your Pi, then run npm install onoff in your project directory.

const { Gpio } = require('onoff');

// Define the hardware pin using BCM numbering
const FAN_PIN = 18;
const fan = new Gpio(FAN_PIN, 'out');

// Track state for graceful shutdown
let isRunning = false;

async function controlFan() {
  try {
    console.log(`Initializing GPIO ${FAN_PIN}...`);
    
    // Turn fan ON
    await fan.write(1);
    isRunning = true;
    console.log('Fan turned ON. Running for 5 seconds...');

    // Wait for 5 seconds
    await new Promise(resolve => setTimeout(resolve, 5000));

    // Turn fan OFF
    await fan.write(0);
    isRunning = false;
    console.log('Fan turned OFF.');

  } catch (err) {
    console.error('GPIO Operation Failed:', err.message);
    console.error('Stack:', err.stack);
    cleanupAndExit(1);
  }
}

function cleanupAndExit(code = 0) {
  console.log('Cleaning up GPIO resources...');
  if (isRunning) {
    try {
      fan.writeSync(0); // Force low on exit
    } catch (e) {
      // Ignore sync write errors during cleanup
    }
  }
  fan.unexport();
  process.exit(code);
}

// Handle graceful shutdown on Ctrl+C or system kill signals
process.on('SIGINT', () => {
  console.log('\nCaught SIGINT. Shutting down safely...');
  cleanupAndExit(0);
});

process.on('SIGTERM', () => {
  console.log('\nCaught SIGTERM. Shutting down safely...');
  cleanupAndExit(0);
});

// Execute the main logic
controlFan();

Debugging: Exact Error Strings & Ranked Causes

When working with Node.js for Raspberry Pi hardware access, the OS layer will frequently block your script. If your code crashes, look for these exact error strings in your terminal.

Error 1: Error: EACCES: permission denied, open '/sys/class/gpio/export'

What it means: Your current Linux user does not have read/write permissions to the sysfs GPIO virtual filesystem.

Ranked Causes & Fixes:

  1. Running as standard user without udev rules: Fix this by adding your user to the gpio group (sudo usermod -aG gpio $USER) and logging out/in, or set up a custom udev rule to grant group permissions to /sys/class/gpio/*.
  2. AppArmor/SELinux blocking access: Rare on standard Raspberry Pi OS, but if you are running a hardened Ubuntu Server image, check dmesg for AppArmor denials.

Error 2: Error: ENOENT: no such file or directory, open '/sys/class/gpio/export'

What it means: The sysfs GPIO interface does not exist on your system. This is the Pi 5 Gotcha.

Ranked Causes & Fixes:

  1. You are using a Raspberry Pi 5: The Pi 5 uses the RP1 chip, and the Raspberry Pi Foundation officially deprecated the sysfs interface in favor of the gpiod (GPIO character device) API. The onoff library will not work. Fix: Switch to the libgpiod bindings for Node.js, or downgrade to a Pi 4 for legacy sysfs projects.
  2. sysfs is disabled in config: On newer Pi OS releases, sysfs might be disabled by default. Fix: Add gpio=18=op,dh or re-enable the sysfs interface via sudo raspi-config under Interfacing Options.
🔍 The First Three Things to Check When It Fails:
  1. Physical vs. BCM Pin Mismatch: Did you wire physical Pin 12 but define const FAN_PIN = 12 in the code? The onoff library uses BCM numbering (which is 18 for physical pin 12).
  2. Library Compatibility: Are you on a Pi 5? If yes, onoff will fail. Verify your board revision with cat /proc/device-tree/model.
  3. Zombie GPIO Locks: Did a previous script crash without calling unexport()? The OS might still think the pin is in use. Run echo 18 > /sys/class/gpio/unexport manually to clear the lock.

Extending and Simplifying the Build

How to Simplify

If breadboard wiring and MOSFET gate capacitance calculations feel like overkill for your use case, simplify the hardware by using a pre-assembled Relay HAT. The Pimoroni Automation HAT or the Waveshare Relay HAT plug directly into the 40-pin header. They include onboard opto-isolators, flyback diodes, and transistor drivers. You simply map the I2C or direct GPIO pins in your Node.js script and toggle them without worrying about inductive kickback destroying your Pi.

How to Extend

To turn this local script into a networked IoT node, wrap the GPIO logic in an MQTT subscriber. By installing the mqtt npm package, your Pi can listen to a Home Assistant broker topic (e.g., homeassistant/fan/office/set). When a payload of ON arrives, the callback triggers fan.write(1). Because Node.js is inherently event-driven, the MQTT listener and the GPIO state machine will run concurrently without blocking each other, making it vastly superior to synchronous Python scripts for this specific architecture.

Frequently Asked Questions

Is Node.js for Raspberry Pi 5 GPIO different than Pi 4?

Yes, fundamentally. The Raspberry Pi 4 uses the BCM2711 SoC, which exposes GPIO via the legacy Linux sysfs interface (compatible with the onoff library). The Raspberry Pi 5 uses the external RP1 I/O controller, which requires the modern libgpiod character device interface. If you are writing Node.js for Raspberry Pi 5, you must use a gpiod-compatible npm package like node-gpiod, as older libraries will throw ENOENT errors.

Why does my Node.js script exit immediately after setting the GPIO high?

Node.js is asynchronous and event-driven. If your script only contains a fan.writeSync(1) command and nothing else, the V8 engine reaches the end of the call stack, determines there are no pending events or timers in the event loop, and cleanly exits the process. To keep it running, you must introduce a persistent event listener (like an HTTP server or MQTT client) or a setInterval timer.

Can I use Node.js for Raspberry Pi to read analog sensors?

Not natively. The Raspberry Pi (all models, including the Pi 5) does not have a built-in Analog-to-Digital Converter (ADC). To read analog sensors (like an LDR or potentiometer) with Node.js, you must wire an external ADC module, such as the MCP3008, via the SPI bus, or use an I2C ADC like the ADS1115. You can then use the spi-device or i2c-bus npm packages to read the digital conversions.

How much memory does a Node.js GPIO script consume on a Pi Zero 2 W?

A bare-bones Node.js script using the onoff library typically idles around 25MB to 35MB of RAM. On a Raspberry Pi Zero 2 W (which has 512MB of RAM), this is perfectly manageable. However, if you add heavy frameworks like Express.js or load large JSON payloads into memory, you can easily spike past 100MB. For highly constrained environments, consider stripping out V8 overhead by using C++ via Node-API, or switching to a lightweight runtime like MicroPython.