Node.js 2026:事件循環、安全單例與現代並行處理
Node.js in 2026: The Event Loop, Safe Singletons, and Modern Parallelism
Node.js 要構建可擴展應用,須掌握事件循環效能、共享狀態、請求隔離及並行處理。Node.js 20 透過 libuv 1.45.0 調整計時器處理時機;libuv 執行緒池預設有四條執行緒。AsyncLocalStorage 可在非同步操作中維持隔離的請求上下文。
Node.js has evolved significantly, but its core architecture remains built around one important principle: keep the main JavaScript thread non-blocking.
As applications become more complex, understanding asynchronous execution alone is no longer enough. Developers also need to understand event loop performance, shared state, request isolation, and parallel processing.
In this article, we'll explore how these concepts work together and how to build scalable Node.js applications in 2026.
1. Understanding the Node.js Event Loop
Node.js runs JavaScript on a single main thread by default. However, this doesn't mean it can only handle one operation at a time.
It relies on libuv, a C library that manages asynchronous I/O and provides a thread pool for operations that cannot be handled directly through non-blocking operating system APIs.
This architecture allows Node.js to handle multiple network requests, file operations, and other asynchronous tasks without waiting for each one to finish.
Event Loop Phases
The event loop processes callbacks through several phases:
-
Timers: Executes eligible
setTimeout()andsetInterval()callbacks. - Pending Callbacks: Handles certain deferred system-level I/O callbacks.
- Poll: Processes incoming I/O events and waits for new events when necessary.
-
Check: Executes
setImmediate()callbacks. - Close Callbacks: Handles events such as socket closures.
The idle and prepare phases also exist but are primarily internal to Node.js.
An important change arrived with Node.js 20, through libuv 1.45.0: timers are now processed after the poll phase on each event loop iteration, rather than both before and after it. This can affect the interaction between timers and setImmediate().
setTimeout() vs setImmediate()
These two functions are often confused.
setTimeout(() => {
console.log("Timeout");
}, 0);
setImmediate(() => {
console.log("Immediate");
});
When called from the main module, their execution order is not guaranteed.
However, inside an I/O callback, setImmediate() executes before the timer callback:
import fs from "node:fs";
fs.readFile(import.meta.filename, () => {
setTimeout(() => console.log("Timeout"), 0);
setImmediate(() => console.log("Immediate"));
});
Understanding this difference helps prevent timing-related bugs.
Microtasks and process.nextTick()
Promises use the microtask queue, while process.nextTick() uses a separate next-tick queue.
Both are processed at specific points before Node.js continues normal event loop execution.
However, recursively scheduling process.nextTick() can prevent the event loop from reaching I/O operations.
function recursiveTask() {
process.nextTick(recursiveTask);
}
recursiveTask();
This is an example of event loop starvation. Avoid creating endless chains of high-priority callbacks.
2. Event Loop Performance: What Should You Monitor?
Knowing how the event loop works is useful, but measuring its performance is even more important.
Event Loop Delay measures how late the event loop executes scheduled callbacks compared with their expected execution time.
High delay can indicate:
- CPU-intensive JavaScript operations.
- Synchronous file system operations.
- Expensive JSON processing.
- Complex regular expressions.
- Excessive callback execution.
Node.js provides monitorEventLoopDelay() through the built-in perf_hooks module.
import { monitorEventLoopDelay } from "node:perf_hooks";
const histogram = monitorEventLoopDelay({
resolution: 20
});
histogram.enable();
setInterval(() => {
const p99 = histogram.percentile(99) / 1e6;
console.log(`Event Loop Delay P99: ${p99.toFixed(2)} ms`);
histogram.reset();
}, 10000);
Monitoring event loop delay alongside CPU usage, memory consumption, and request latency provides a much clearer picture of application health.
Another important component is the libuv thread pool.
Some file system, DNS, cryptographic, and compression operations use this pool. Its default size is four threads, and it can be configured using UV_THREADPOOL_SIZE.
When too many expensive operations compete for these threads, otherwise unrelated tasks may experience delays.
Remember: asynchronous does not automatically mean unlimited scalability.
3. The Evolution of the Singleton Pattern
The Singleton pattern is a familiar design pattern that ensures a resource is created once and reused.
In Node.js, module caching makes this relatively easy.
For example, CommonJS caches modules after their first load. Subsequent calls to require() for the same resolved file return the cached module.
With modern ES modules, module-level variables are also a convenient way to share resources.
// database.js
export const database = createDatabaseConnection();
Other modules importing this resource can reuse the same instance within that runtime.
However, this simplicity introduces an important problem: shared mutable state.
Imagine multiple HTTP requests using the same singleton service. If the service stores request-specific information in its properties, one request may accidentally affect another.
This is especially dangerous when dealing with authentication, user sessions, tenant information, and temporary request data.
The Solution: AsyncLocalStorage
Node.js provides AsyncLocalStorage to maintain isolated context across asynchronous operations.
It allows request-specific information to follow an asynchronous execution chain without passing it manually through every function.
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
export const requestContext = new AsyncLocalStorage();
// Express middleware
app.use((req, res, next) => {
requestContext.run(
{
requestId: randomUUID(),
userId: req.user?.id
},
next
);
});
Any service can access the current request context:
export function log(message) {
const context = requestContext.getStore();
console.log({
requestId: context?.requestId,
message
});
}
This provides request-level context while allowing shared services to remain singletons.
A useful distinction: AsyncLocalStorage isolates context, not arbitrary mutable objects. Keep request data inside its own context and avoid placing user-specific state on shared singleton instances.
4. Async Initialization: Avoid Duplicate Instances
Singletons also introduce another common problem: asynchronous initialization.
Consider a database client that takes time to initialize.
If multiple requests call the initialization function before the first operation finishes, the application may accidentally create multiple connections.
A simple solution is to cache the initialization Promise.
let clientPromise;
export function getDatabaseClient() {
if (!clientPromise) {
clientPromise = createDatabaseClient().catch((error) => {
clientPromise = undefined;
throw error;
});
}
return clientPromise;
}
Now, concurrent callers receive the same Promise and wait for the same initialization process.
Resetting the Promise after failure also allows a later call to retry initialization.
For production applications, remember to combine this approach with proper connection lifecycle management and reconnection handling.
5. Moving Beyond One Thread: Worker Threads
The event loop is excellent for handling asynchronous I/O, but CPU-intensive JavaScript can still block the main thread.
Examples include:
- Image processing.
- Large data transformations.
- Complex mathematical calculations.
- Machine learning inference.
- Expensive parsing operations.
For these workloads, Node.js provides the built-in worker_threads module.
Workers execute JavaScript in separate threads, each with its own V8 instance and event loop.
Importantly, Worker Threads and the libuv thread pool are not the same thing.
The libuv pool handles certain background operations. Worker Threads allow developers to execute their own JavaScript workloads in parallel.
Why Worker Pools Matter
Creating a new worker for every incoming request is usually inefficient.
Worker startup introduces memory and initialization overhead. Instead, a better approach is to maintain a fixed number of workers and distribute tasks between them.
This is known as the Worker Pool pattern.
Incoming Tasks
|
v
Task Queue
|
v
Worker Pool
/ | \
v v v
W1 W2 W3
\ | /
Results
A worker pool reuses existing threads rather than creating new ones for every task.
For ordinary network requests, database queries, and asynchronous file operations, Node.js's built-in asynchronous APIs are generally the better choice.
6. Sharing Data Between Worker Threads
Workers need a way to communicate and exchange information.
Node.js provides two common approaches.
1. Message Passing
Workers can exchange messages using postMessage().
worker.postMessage({
task: "process-data",
payload: [10, 20, 30]
});
worker.on("message", (result) => {
console.log(result);
});
Objects are generally transferred using structured cloning. This provides isolation because each worker receives its own copy.
For large buffers, transferable ArrayBuffer objects can avoid unnecessary copying by transferring ownership.
2. SharedArrayBuffer and Atomics
For scenarios requiring shared memory, Node.js supports SharedArrayBuffer.
Multiple threads can access the same memory region without copying data.
However, this introduces synchronization challenges.
const sharedBuffer = new SharedArrayBuffer(4);
const sharedArray = new Int32Array(sharedBuffer);
Atomics.add(sharedArray, 0, 1);
Atomics provides operations that help coordinate access to shared memory.
Without proper synchronization, concurrent writes can produce inconsistent results and difficult-to-debug race conditions.
For most applications, message passing is the simpler starting point. Use shared memory only when its performance benefits justify the additional complexity.
7. Framework Choices in 2026
The Node.js ecosystem offers several approaches to building backend applications.
Express
Express remains a flexible option with a large ecosystem and minimal architectural restrictions. It works well for smaller services, prototypes, and existing applications.
Fastify
Fastify focuses on performance, schema-based validation, and efficient request handling. It is worth considering for high-throughput APIs.
NestJS
NestJS provides a structured architecture with dependency injection, modules, decorators, and lifecycle management. It is particularly useful for large TypeScript applications and teams.
There is no universally best framework.
The right choice depends on performance requirements, application complexity, team experience, and maintainability. Always benchmark using your actual workload rather than relying entirely on generic benchmark results.
8. Singleton Patterns in Serverless Applications
Singleton behavior becomes more interesting in serverless environments such as AWS Lambda.
A serverless execution environment may be reused across multiple invocations. Therefore, module-level variables and singleton instances can survive between requests.
This can be useful for expensive resources such as database clients and SDK instances.
For example:
import { S3Client } from "@aws-sdk/client-s3";
const s3 = new S3Client({
region: process.env.AWS_REGION
});
export const handler = async (event) => {
// Reuse the existing client
};
Creating a new SDK client for every invocation is often unnecessary.
However, never store user-specific mutable information in a global singleton.
A useful rule is:
- Reuse expensive infrastructure clients where supported.
- Keep request-specific data local to each invocation.
- Never assume module-level state is durable.
- Use lazy initialization for expensive optional dependencies.
- Keep deployment bundles small to reduce cold-start overhead.
Also remember that multiple serverless execution environments can run independently. A singleton is never a replacement for shared, durable distributed storage.
來源:DEV Community · dev.to