Skip to content
Back to blog

NodeJS Knowledge Points

Notes on the Node.js event loop, file watching, performance debugging, and process management, plus a discussion of GraphQL queries and the N+1 problem.

Updated

NodeJS Performance Advantages

  • It combines a single thread with an event loop, avoiding the overhead of thread switching and lock contention in multithreading
  • The single thread reduces memory overhead (each Java thread takes about 2MB, while Node.js only needs tens of KB)
  • The Cluster module (a core module for creating multiple processes, supporting a master process + worker process architecture) enables lightweight scaling, processing requests in parallel across multiple processes to improve throughput.
  • It uses a non-blocking I/O model; handling I/O does not require spawning new threads — asynchronous calls on the single thread are enough.

The NodeJS Event Loop

NodeJS has synchronous tasks and asynchronous tasks. Under the hood, all asynchronous tasks are backed by libuv, a library that abstracts cross-platform low-level capabilities such as sockets, threads, I/O, etc.

So the NodeJS event loop is consistent with the libuv event loop.

Event Loop Phases

The whole NodeJS event loop is divided into the following phases:

  • Timers: executes callbacks of setTimeout() and setInterval()
  • I/O Callbacks: handles asynchronous I/O callbacks
  • Idle, prepare: internal use, rarely seen
  • Poll: polls for new I/O events and executes I/O-related callbacks
  • Check: executes setImmediate callbacks
  • Close Callbacks: handles close callbacks, such as socket.on('close', () => {})
   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│            nextTick
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│            nextTick
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘
│            nextTick                 ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│            nextTick                 └───────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │
│  └─────────────┬─────────────┘
│            nextTick
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

Event Loop Flow

NodeJS uses libuv under the hood for asynchronous I/O, so the whole event flow is consistent with the event loop in libuv

  • handle: a long-lived object that can perform certain operations while active.
  • request: usually represents a short-lived operation.

The overall flowchart is as follows:

graph TD
  A[Initialize loop time] --> B[Run due timers]
  B --> C{Is the event loop alive?}
  C -->|No| D[End]
  C -->|Yes| E[Call pending callbacks]
  E --> F[Run idle handles]
  F --> G[Run prepare handles]
  G --> H[Poll for I/O]
  H --> I[Run check handles]
  I --> J[Call close callbacks]
  J --> K[Update loop time]
  K --> B
  1. Initialize loop time: initialize now.
  2. Run due timers: run the timers that are due. Every active timer checks whether its call time is before now; if so, its callback is invoked in this phase.
  3. Is the event loop alive?: if the event loop is alive (there are active loop / ref'd handles / active requests / closing handles), it enters the loop; otherwise the loop is skipped and this task ends.
  4. Call pending callbacks: call the deferred callbacks. Most I/O callbacks are called immediately in the poll for I/O phase, but in some cases these callbacks are deferred to the next event loop cycle. When such deferral happens, those callbacks are called here.
  5. Run idle handles: call the idle handle callbacks.
  6. Run prepare handles: prepare handles call their callbacks immediately before the loop blocks for I/O. They are usually called internally by NodeJS for pre-allocating resources, resource initialization, etc.
  7. Poll for I/O:
  8. Compute the I/O blocking timeout: use an algorithm to compute the timeout — before blocking for I/O, calculate how long it should wait before timing out. If there are no other tasks to execute, it never times out; otherwise the call time of the nearest upcoming task is used as the timeout.
  9. Block for I/O: block on I/O for the computed timeout; if any I/O callback is triggered during this period, it is executed immediately.
  10. Run check handles: execute callbacks that need immediate handling, such as setImmediate.
  11. Call close callbacks: callbacks triggered by close events such as closing a socket or closing I/O.
  12. Update loop time: update the now value and start the next cycle.

process.nextTick()

process.nextTick() is not part of the event loop; it is triggered whenever each event loop phase completes.

File Watching

  • fs.watch: watches file changes through the operating system's underlying API; requires read permission on the file, and may have OS compatibility issues
  • fs.watchFile: checks the file's last modified time via periodic polling; works on all systems, but has performance problems when watching a large number of files or directories.

Performance Optimization

How to Debug

Run a NodeJS application with node --inspect-brk entry.js; the application will be paused at a breakpoint and will not execute.

Open chrome://inspect in Chrome for remote debugging — you can connect to the NodeJS application that was started in debug mode; across networks you can specify an IP + port.

Use Chrome's debugging panel to record CPU, memory, and other overhead, run load tests with traffic at the same time, and collect data to analyze time-consuming operations.

Memory Optimization

Buffers allocate memory in minimum units of 8KB. The overall flow is as follows:

flowchart TD
    A[Buffer creation request] --> B{Buffer size check}
    B -->|"4KB or less"| C[Check remaining space in the current slab]
    B -->|"More than 4KB"| D[Allocate a dedicated SlowBuffer directly]
    C --> E{Enough remaining space?}
    E -->|Yes| F[Allocate memory from the current slab]
    E -->|No| G["Create a new slab pool<br>(8KB ArrayBuffer)"]
    G --> H[Reset poolOffset to 0]
    F --> I["Update poolOffset<br>poolOffset += size"]
    I --> J[Memory alignment<br>8-byte alignment]
    J --> K[Return a FastBuffer instance]
    D --> L[Return a dedicated SlowBuffer instance]

    style A fill:#f9f,stroke:#333
    style D fill:#f96,stroke:#333
    style K fill:#6f9,stroke:#333
    style L fill:#6f9,stroke:#333

Similarly, when hitting a memory bottleneck, you can consider a custom memory pool strategy to optimize memory usage — trading time for space.

Converting to C++ Code

Modules compiled with node-gyp can only run on the specific operating system and NodeJS version they were built for.

For compilation, see the official documentation chapter C++ addons

The build script has a fixed name: binding.gyp

{
  "targets": [
    {
      // Name of the compiled addon
      "target_name": "addon",
      // Declare the entry file of the C++ module
      "sources": [ "hello.cc" ]
    }
  ]
}

A hello world example:

// hello.cc
#include <node.h>

namespace demo {
  using v8::FunctionCallbackInfo;
  using v8::Isolate;
  using v8::Local;
  using v8::NewStringType;
  using v8::Object;
  using v8::String;
  using v8::Value;

  // Output 'world'
  void Method(const FunctionCallbackInfo<Value>& args) {
    // Get the execution stack of the current JS thread
    Isolate* isolate = args.GetIsolate();

    // Get the reference to the JS function's return value
    args.GetReturnValue()
      // Set what the return value should be
      .Set(
        // Create the string 'world' in the isolate
        // and set it as the function's return value
        String::NewFromUtf8(
          isolate,
          "world",
          NewStringType::kNormal
        )
      .ToLocalChecked());
  }

  // Module initialization function, defining what the module exports
  void Initialize(Local<Object> exports) {
    // Define that the module exports a hello function, corresponding to the Method function defined in C++
    NODE_SET_METHOD(exports, "hello", Method);
  }

  // NODE_MODULE is a C++ macro, replaced at compile time with the module definition function
  // NODE_GYP_MODULE_NAME is also a macro, replaced at compile time with the target_name configured in binding.gyp
  // This declares the module entry and which function to call to initialize the module
  NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
}

Using Worker Threads (worker_threads)

Using worker_threads to spawn child threads for computation can effectively reduce performance overhead.

Child threads cannot allocate resources independently; they share memory space with the main thread, so they are not suitable for high-memory tasks — they are suited for CPU-intensive tasks.

Using Child Processes (child_process)

Creating child processes via child_process avoids the problem of shared resources between threads; each child process gets its own independent memory space for computation, making it more flexible than child threads.

Since resources are not shared, avoid transferring large files or large payloads when communicating with the main process.

With cluster, you can bind each child process to a CPU core, increasing its share of CPU time slices for faster execution.

---
title: cluster Parent-Child Communication Sequence Diagram
---
sequenceDiagram
    participant Master
    participant Worker

    Master->>+Worker: cluster.fork()
    activate Worker
    Worker-->>Master: online event
    Master->>Worker: send(task/handle)
    Worker->>Worker: Process the task
    Worker-->>Master: send(result)
    Master->>Worker: send(shutdown signal)
    Worker-->>Master: exit event
    deactivate Worker

Stability

Error Reporting

// Listen for exceptions
process.on('uncaughtException', (err) => {
  // Report the exception
  console.error(err)

  // If you don't exit here, nodejs will not terminate the process.
  // But the process may already have crashed and can no longer work properly;
  // not exiting may actually cause abnormal behavior
  process.exit(1)
})

Automatically Restarting Child Processes

// Listen for child process exit
// Can be replaced with child_process
cluster.on('exit', () => {
  // Add a delay and a maximum retry count, to avoid crashing immediately after restart and killing the CPU
  setTimeout(() => {
    // Restart a child process
    cluster.fork()
  }, 5000)
})

Monitoring Memory Leaks and Auto-Restarting

setInterval(() => {
  // Finer-grained control is possible, such as continuously growing memory;
  // customize the detection strategy as the business needs.
  // Here we check whether memory usage exceeds a threshold
  if (process.memoryUsage().rss > (70 * 1024 * 1024)) {
    // Report the memory leak
    console.log('leak')

    // Terminate the process
    process.exit(1)
  }
}, 5000)

Monitoring Zombie Processes

Heartbeats can be used to monitor whether a child process has been suspended or has fallen into an infinite loop, becoming a zombie process.

// Accumulated missed heartbeats
let missedPing = 0
// Heartbeat threshold
const maxMissedPing = 5
// Create a child process
const childProcess = cluster.fork()
childProcess.on('message', (msg) => {
  if (msg === 'pong') {
    missedPing--
  }
})

const interval = setInterval(() => {
  childProcess.send('ping')
  missedPing++

  // Too many missed heartbeats — shut down the process
  if (missedPing > maxMissedPing) {
    // Clear the timer to avoid sending more messages to a dead process
    clearInterval(interval)

    // Report when necessary
    console.error('missed ping over max')

    // Shut down the child process
    // childProcess can no longer respond to external events,
    // so we call the operating system's kill to force it to close
    process.kill(childProcess.process.pid)
  }
}, 300)

GraphQL

GraphQL can basically be broken down into the following stages:

  • parsing: the parsing phase. Generates an AST of the Query String through lexical and syntactic analysis
  • Validation: the validation phase. Divided into built-in validation and custom validation; built-in validation checks whether variables are defined, whether fields exist, whether fragments exist, and so on.
  • Execution: the execution phase. Starts executing the query, fetches field values according to the predefined resolver functions, and merges them into the response

Workflow

---
title: 'GraphQL Workflow Diagram'
---
graph TD
  A(Initiate Request) --> B[HTTP Server]
  B -->|Parse request body| C[Get GraphQL Query String]
  C --> D[Parse Query String]
  D -->|Generate| E[AST]
  E --> F[Validator]
  F -->|Valid?| G{Valid?}
  G -->|Yes| H[Executor]
  G -->|No| I[Return error]
  H --> J[Resolve Fields]
  J --> K[Type system processing]
  K --> L{Sub-fields needed?}
  L -->|Yes| M[Recursive resolution]
  L -->|No| N[Serialize result]
  M --> N
  N --> O[Merge results]
  O --> P[JSON Response]
  I --> P
  P --> Q[Return to client]

  classDef process fill:#e1f5fe,stroke:#039be5;
  classDef decision fill:#fff3e0,stroke:#fb8c00;
  classDef data fill:#f0f4c3,stroke:#cddc39;

  class A,B,C,Q process;
  class D,F,H,J,K,L,M,N,O process;
  class G decision;
  class E,P data;

Query String

The Query String itself borrows syntax from many mainstream languages

Take JSON syntax as an example

  1. Define object-value semantics as aliases
  2. Remove all , and "
  3. Put the query conditions (${arg1}: ${alias1}, ${arg2}: ${alias2}) on the corresponding fields
  4. Manage fields with the directives @include, @skip, @deprecate
  5. Reuse data structures with ...${fragment}
  6. Add the operation to perform at the outermost level: query (query), mutation (mutation), or waiting for server pushes (subscription)

That is, the following example:

# The query needs to carry the groupID parameter
query GetGroupWithUsers($groupID: ID!) {
  # Pass the query parameter to group
  group(id: $groupID) {
    id
    name
    description
    users {
      id
      name
      email
      # Nested related-data query
      posts {
        id
        title
        content
      }
      friends {
        id
        name
      }
    }
  }
}

Only the parameter part of the Query String involves type definitions; field types are predefined on the server side and never change, so the posts field is automatically resolved as an array without the client managing types.

As for where fields come from — reading/writing a database, file I/O, rpc, or fetching from other APIs — GraphQL does not care, and neither does the client; only the server-side developer who writes the resolver for the corresponding field needs to care.

The N + 1 Problem

The N + 1 problem means: when querying all article data, you first need to fetch all article IDs — 1 query — and then fetch each article by its ID — N queries.

In RESTful, the N + 1 problem manifests as a large number of concurrent HTTP requests; in GraphQL it corresponds to a large number of resolver calls. Whether a resolver operates a database, rpc, file I/O, or calls other APIs, frequent invocation always causes performance problems.

The key to solving the problem is how to cache query values (write) and how to batch write operations (write):

  • Custom handling mechanisms when a request fails:
    • Remove/delete the cache entry (may cause cache penetration)
    • Cache the error value (may cause a temporary error to be persisted)
    • Manually counted caching
  • Associate cached values with parameters
  • Be able to index previously cached values on the next query
  • Cache eviction mechanism
  • Batch processing

GraphQL officially provides dataLoader to implement the above. This loader can be used beyond GraphQL — RESTful APIs can reuse it too. It is essentially an abstracted asynchronous-operation cache layer.

Miscellaneous

  • Because the GraphQL Query String cannot declare a return value type, it does not have to return JSON — it can also return text, html, etc.
  • GraphQL is essentially a process of querying data according to a DSL (Resolver) and then generating code. Since the DSL abstracts the entire query process, the Resolver doesn't even have to be a backend program — it can be the frontend's global state, IndexedDB, LocalStorage, or any other store