The Overhead of Frameworks
Opening ten Node.js microservice tutorials often reveals a familiar pattern: installing Express, an HTTP client, a Docker Compose file, and a message broker. By the time you reach the actual application code, you’ve introduced six dependencies and might still question if microservices were the right architectural choice. This common starting point introduces significant overhead before developers even write business logic. Frameworks like Express, while powerful and convenient for many web applications, can be overkill for the specific needs of inter-service communication in a microservice architecture. They bundle features and abstractions that aren't strictly necessary for two services to talk to each other. This leads to larger deployment artifacts, increased memory footprints, and a larger attack surface, all without providing tangible benefits for the core task of service-to-service calls.
The Node.js runtime, however, ships with a surprisingly robust set of built-in modules that can handle the fundamental requirements of building communicating services. The `node:http` module provides the server capabilities, Global `fetch` serves as the client for making HTTP requests, `node:test` offers a built-in testing framework, and the `--watch` flag enables live reloading during development. This means it's entirely possible to construct microservices without relying on external packages like Express or `node-fetch`. The benefits are immediate: no `node_modules` directory to manage, reduced installation times, and leaner deployments. Developers can focus on the core business logic and the complexities of distributed systems rather than managing framework dependencies.
A Practical Implementation: User and Order Services
To demonstrate this zero-dependency approach, consider two fundamental services: a User Service and an Order Service. The User Service is responsible for managing user records, while the Order Service needs to confirm a user’s existence before creating an order for them. This necessitates a cross-service communication pattern, where the Order Service must call the User Service.
In a zero-dependency setup, the User Service would expose its API using `node:http`. A simple server can be instantiated with:
import http from 'http';
const server = http.createServer((req, res) => {
// Request handling logic here
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'Hello from User Service!' }));
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`User Service running on port ${PORT}`);
});
The Order Service, acting as the client, would then use the Global `fetch` API to communicate with the User Service. This is the same `fetch` available in modern browsers and is now a standard part of Node.js. A request from the Order Service to verify a user might look like this:
async function verifyUser(userId) {
try {
const response = await fetch(`http://user-service:3000/users/${userId}`);
if (!response.ok) {
throw new Error(`User verification failed: ${response.statusText}`);
}
const userData = await response.json();
return userData;
} catch (error) {
console.error('Error verifying user:', error);
throw error; // Re-throw to indicate failure
}
}
// Example usage in Order Service:
async function createOrder(orderData) {
try {
const user = await verifyUser(orderData.userId);
if (!user) {
throw new Error('User not found.');
}
// Proceed with order creation logic...
return { success: true, order: orderData };
} catch (error) {
return { success: false, error: error.message };
}
}
Encountering Real Distributed System Failures
Building services this way forces developers to confront the actual failure modes of distributed systems, not just theoretical ones. For instance, when a network connection to the User Service is refused (perhaps the service is down or hasn't started yet), the `fetch` call won't simply return a response object with an error status. Instead, it will reject, throwing an unhandled rejection within the request handler. This is a critical detail: an unhandled rejection in a request handler can crash the entire Node.js process if not properly managed. This contrasts with some frameworks that might abstract this into a specific error response object, potentially masking the underlying instability.
This direct exposure to connection errors, timeouts, and other network issues necessitates robust error handling and retry mechanisms. Developers must implement strategies like exponential backoff or circuit breakers directly, rather than relying on framework-provided solutions that might not perfectly fit the distributed context. Testing also becomes more direct. Using `node:test`, developers can write integration tests that spin up minimal instances of their services and verify communication using actual HTTP requests, ensuring that the core `fetch` and `http` modules behave as expected under various conditions, including simulated failures.
The Proxy Pitfall: getaddrinfo on the Hot Path
While building lean microservices is achievable with Node.js core modules, the surrounding infrastructure still matters. A common pitfall, even with performant proxies, is placing the `getaddrinfo` system call on the critical request path. Cloudflare’s experience with their Pingora proxy library illustrates this. Benchmarking Pingora 0.9.0 against Nginx in a constrained container environment showed Pingora to be six times slower (21k requests/sec vs. 126k requests/sec).
The initial assumption was resource contention: CPU throttling, thread oversubscription, or connection reuse issues. However, isolating the problem revealed the true culprit: latency introduced by `getaddrinfo` when resolving upstream hostnames. The difference between a local resolution (e.g., `127.0.0.1`) and a DNS lookup for a named service, even within a small pod, can be substantial. In this case, the latency jumped from 0.45 ms on the host to 4.78 ms within the container, solely due to how the backend destination was named and resolved.
This highlights that while the microservice itself might be lean, the network layer and service discovery mechanisms can become performance bottlenecks. For proxies and load balancers handling high traffic, minimizing or eliminating DNS lookups and other blocking system calls on the request path is paramount. Strategies involve aggressive caching of DNS records, using pre-resolved IP addresses where possible, or employing service discovery systems that provide direct IP mappings rather than hostnames that require dynamic resolution.
Conclusion: Leaner Services, Deeper Understanding
By eschewing frameworks like Express and leveraging Node.js’s built-in `http` and `fetch` modules, developers can build leaner, more efficient microservices. This approach not only reduces dependencies and deployment size but also forces a more intimate understanding of the challenges inherent in distributed systems, such as handling network-level failures directly. While the services themselves become more resource-friendly, it remains crucial to optimize the surrounding infrastructure, particularly proxies and service discovery, to avoid introducing latency through operations like hostname resolution on critical paths. The result is a more resilient and performant microservice architecture, built with a deeper appreciation for its fundamental components.
