The Definitive Guide For NodeJs in Enterprise (Interactive)
The Definitive Guide For NodeJs in Enterprise (Interactive)
Introduction 2
1 The basics of Node.js, who we are, and why we wrote this book
Appendix A 278
A The Node.js Event Loop
1
Introduction
With years of experience helping Fortune 500 companies build and scale their most
critical Node.js applications, we’ve seen firsthand the challenges teams face when
trying to streamline their operations.
That’s why we wrote this book—to share the insights, patterns, and best practices
we’ve developed over the years, helping companies succeed with Node.js at scale.
4
One of the most common misconceptions about Node.js is that it’s simple.
In some ways, that’s true: getting a basic application up and running is remarkably
easy. The lightweight nature of Node.js, its vast ecosystem, and the accessibility of
JavaScript make it an appealing choice for companies looking to move fast.
But while learning the basics is straightforward, mastering Node.js is an entirely different
challenge.
As companies grow and applications scale, teams often struggle with the complexities
that arise. Efficient event-driven architectures, proper concurrency management,
security best practices, and optimizing performance under heavy loads are just a few
of the challenges that separate a working Node.js application from an enterprise-grade
one.
Moreover, hiring seasoned Node.js engineers is notoriously difficult. The demand for
experienced developers far outpaces supply, leaving many organizations struggling
to build strong backend teams. As a result, companies often resort to workarounds,
patchwork solutions, and ad-hoc performance optimizations that can ultimately slow
development and delay go-to-market timelines.
At Platformatic, we’ve taken all our expertise and poured it into both building a
powerful enterprise-ready platform for Node.js and sharing our knowledge with the
broader community. Our goal is to help teams navigate the complexities of Node.js with
confidence in enterprise settings.
This book is a reflection of that mission. Whether you’re an engineering leader looking
to streamline development workflows, a senior developer seeking to fine-tune your
application’s performance, or a junior developer eager to level up your skills, this book
will serve as a practical guide to mastering Node.js in an enterprise environment.
5
Luca Maraschi
CO-FOUNDER & CEO, PLATFORMATIC
Luca Maraschi started coding at the age of six on a Commodore 64. By eight, he
was writing code in C and decided to dedicate his career to pursuing his passion for
engineering and alchemy.
Prior to co-founding Platformatic, he founded and exited three companies and held
tech leadership roles at mobileLIVE and Telus Digital.
15+
years of building and operating enterprise applications
3
Companies built & exited
6
Matteo Collina
CO-FOUNDER & CTO, PLATFORMATIC
10+
years of building Node.js platforms for
Fortune 500 companies
22 B+ 500+
Downloads per year npm modules
12 K+ Stars
25 M / month
22 K+ Stars
5.7 M / month
7
The Road
to Node.js
JavaScript: The Foundation
of Modern Web Development
1 Node.js: Bringing JavaScript to the Server 11
2 Enter npm 12
5 16
7 Node.js’ Governance 19
11 Installing Node.js 24
What is JavaScript?
JavaScript is one of the three core technologies used to develop websites, alongside
HTML and CSS. While HTML provides structure and CSS controls styling, JavaScript
adds functionality and interactivity, enabling dynamic and engaging user experiences.
Originally a client-side scripting language, JavaScript has grown into a key player in
server-side programming, mobile app development, and even desktop applications.
Its lightweight syntax and event-driven nature make it both easy to learn and highly
effective in a variety of contexts, contributing to its widespread adoption.
Over time, JavaScript has surpassed Java, Flash, and other languages due to its open
ecosystem, strong community support, and extensive package registry.
10
2.1 Node.js: Bringing JavaScript to the Server
Since its release in 2009, Node.js has come a long way from being a simple niche
technology.
Node.js
Node.JS Release: Users include:
annual downloads
2009 >2 B
Node.js is a free, open source, cross-platform JavaScript runtime environment that lets
developers build servers, web apps, command line tools and scripts.
This scalability and efficiency make Node.js an ideal choice for building high-
performance, data-intensive applications that can scale to meet the demands of
modern enterprise environments at a fraction of the time and cost of the previous
generation of technologies such as Java or .NET, while striking a balance between
performance and developer experience
This, coupled with its active and ever-growing open source community and the strong
support from the OpenJS foundation– which provides a neutral home for hosting,
governing, and providing financial support for essential open source JavaScript
projects– has made it a pillar of contemporary web development.
1
https://openjsf.org/
11
2.2 Enter npm
12
2.3 TypeScript: Adding Static Types and Structure
to JavaScript
JavaScript’s dynamic typing did not provide enough metadata for advanced
autocompletion and type inference, making it harder to scale in large projects.
Recognizing this limitation, Microsoft developed TypeScript (2012) alongside Visual
Studio Code (2015) to enhance JavaScript’s developer experience. TypeScript brought
stronger tooling, type safety, and maintainability, while Visual Studio Code provided
built-in support for TypeScript, improving developer productivity through intelligent
autocompletion and inline type checking.
One of TypeScript’s key advantages is its ability to introduce optional static typing,
allowing developers to define variable and function types at compile time. This not
only improves code validation but also significantly enhances autocompletion, making
development more efficient and less error-prone.
13
2.4 The Rise of Full-Stack
JavaScript
This full-stack shift made it possible to use JavaScript across the entire development
stack, unifying development efforts and allowing developers to focus on delivering
end-to-end solutions without needing to switch between multiple languages.
At the time, JavaScript code that could execute on both the client and server was
coined “isomorphic JavaScript” by Charlie Robbins, Co-Founder & CEO of Nodejitsu3.
3
https://www.oreilly.com/content/renaming-isomorphic-javascript/
14
Benefits of using Node.js in enterprise applications?
15
2.5 Node.js Usage and Growth
According to the 2024 StackOverflow survey, Node.js is the most used technology.
16
Downloads by Operating System
From 2021 till Today
17
2.6 Best Practice for Node.js Maintenance
Many developers and teams are unintentionally putting their applications at risk by not
updating Node.js. Here’s why staying current is crucial.
Node.js offers a Long-Term Support (LTS) schedule to ensure stability and security
for critical applications. However, versions eventually reach their end-of-life, meaning
they no longer receive security patches.
This leaves applications built with these outdated versions vulnerable to attacks.
The recommended approach is to upgrade every two LTS releases. For instance, if
you’re currently using Node.js 16 (which is no longer supported), you should migrate to
the latest LTS active version, which is currently Node.js 22.
4
https://nodejs.org/en/about/previous-releases
18
2.7 Node.js’ Governance
Collaborators have:
Both collaborators and non-collaborators may propose changes to the Node.js source
code. The mechanism to propose such a change is a GitHub pull request. Collaborators
review and merge (land) pull requests.
Two collaborators must approve a pull request before the pull request can land (one
collaborator approval is enough if the pull request has been open for more than 7 days).
Approving a pull request indicates that the collaborator accepts responsibility for the
change. Approval must be from collaborators who are not authors of the change.
If a collaborator opposes a proposed change, then the change cannot land. The
exception is if the TSC votes to approve the change despite the opposition.
Usually, involving the TSC is unnecessary.
Often, discussions or further changes result in collaborators removing their opposition.
Fundamentally, if you’d like to have a say in the future of Node.js, start contributing!
The project is governed by the Technical Steering Committee (TSC) which is responsible
for high-level guidance of the project.
19
2.8 The Open Source & Open Governance Advantage
The open source and open governance nature of Node.js provides significant advantages
for enterprise teams. Unlike proprietary, vendor-locked solutions, Node.js is not just
open source—it is also community-driven, meaning users have a responsibility to
contribute back to the ecosystem rather than just benefit from it.
• Faster innovation
Enterprises can actively contribute features and improvements
rather than waiting on a vendor’s roadmap.
•
Long-term sustainability
Projects benefit from diverse contributions and long-term stability
because governance is not controlled by a single entity)
20
•
Transparent decision-making
Enterprises can engage directly in discussions, influence the
project’s direction, and ensure it aligns with their needs.
Enterprises should:
21
2.9 Node.js Usage in
Enterprise Settings
Financial Services
Leading financial institutions like American Express (Amex) are adopting
Node.js to build modern, data-driven applications for online banking,
fraud detection, and real-time analytics. The open source nature of
Node.js aligns well with the industry’s growing focus on transparency
and collaboration.
E-commerce
Retail giants like Walmart have utilized Node.js to create highly
responsive and dynamic e-commerce platforms. Node.js allows them
to handle massive user traffic during peak seasons and personalize
shopping experiences in real-time.
Social Media
Social media platforms require real-time updates and constant user
interaction. Node.js empowers these platforms to handle millions of
concurrent connections and deliver a dynamic, engaging user experience.
22
Logistics & Transportation
Ride-hailing apps like Uber leverage Node.js to manage real-time
location tracking, route optimization, and efficient rider-driver matching.
Node.js facilitates smooth information exchange between various parts
of the platform, ensuring a seamless user experience.
Plan changes:
Develop a comprehensive strategy that outlines the steps involved in adding
Node.js to your stack. Consider factors such as project timelines, resource
allocation, and potential impact on existing workflows.
23
2.10 Getting started with Node.js
Before diving into Node.js development, let’s set up your development environment
and create your first application.
This section will guide you through installing Node.js and running a basic web server.
2. Download the LTS (Long Term Support) version recommended for most users
To verify the installation, open your terminal or command prompt and run:
node --version
npm --version
Let’s create a simple web server that responds with “Hello, World!”:
mkdir my-app
cd my-app
24
2. Initialize a new Node.js project:
npm init -y
{
“type”: “module”
}
4. Create a new file named server.js and add the following code:
node server.js
6.
Open your web browser and visit http://localhost:3000. You should see “Hello,
World!” displayed.
25
How can Platformatic help?
For complex projects, consider the Strangler Pattern as a risk-mitigation strategy. This
approach involves gradually wrapping the legacy system with a new Node.js application,
slowly shifting functionality and user traffic to the new system while the legacy system
remains operational.
This minimizes disruption and allows for a more controlled migration process.
This allows you to focus on modernizing and delivering brilliant user experiences, while
we handle the rest.
We are defining the next generation platform for building, deploying and scaling
enterprise Node.js apps. Our unified Node.js platform for enterprises helps with:
26
Wrapping Up
Node.js has played a pivotal role in this transformation by bringing JavaScript to the
server-side, providing enterprises with an efficient, scalable, and flexible solution for
building high-performance applications. With the support of npm, TypeScript, and a
thriving open-source ecosystem, Node.js has become a critical component of both
small startups and large enterprises alike.
In embracing Node.js, enterprises are not only modernizing their technology stacks but
also participating in a thriving global ecosystem, shaping the future of web development.
27
Creating APIs
with Fastify
An Intro to Fastify
1 Fastify Activity 30
10 Connecting to a database 62
12 TypeScript 76
Fastify is a cutting-edge open source web framework for Node.js, known for its
emphasis on speed, efficiency, and an excellent developer experience.
30
Back in 2015, I was working on delivering a high-performance project. My team and
I ended up optimizing this project so much that we could make Node.js saturate the
network cards of our EC2 VMs.
However, because all the available frameworks added too much overhead, we had
to implement all the HTTP APIs using only Node.js core, making the code difficult to
maintain.
It seemed that the Node.js web framework landscape was starting to stagnate and
there wasn’t a solution that would meet my requirements.
I realized that in order to squeeze out the best possible Node performance, I’d have to
create a new framework. I wanted this hypothetical framework to add as little overhead
as possible, while also providing a good developer experience to users.
I knew that the community would be critical to what I wanted to build, and that it
would be a huge effort. I quickly decided that this new web framework would adopt an
open governance model, and that I would start developing it once I convinced another
developer to join me in this effort.
A few months later, in June 2016, while delivering a Node.js training course at
Avanscoperta in Bologna, an attendee asked me how to get started working in open
source.
His name was Tomas Della Vedova, and by the end of the course, I asked him if he
wanted to build this Node.js framework with me. By September, we landed the first
commit of what would later become Fastify.
In the following years, NearForm supported me in this journey and sponsored my time
for developing the framework, while Tomas was sponsored by LetzDoIt - a startup
founded by Luca Maraschi.
31
Usage
In 2024, Fastify was downloaded over 90 million times and doubled its downloads
when compared to 2023.
Plugins Ecosystem
Fastify allows users to extend its functionalities with plugins. A plugin can be a set of
routes, or a server decorator, among other things.
Maintenance
Fastify has five lead maintainers: Matteo Collina, Tomas Della Vedova, Manuel Spigolon,
Kaka Ng and James Sumners, as well as an additional 8 collaborators.
The team is actively maintaining Fastify, with regular updates and a vibrant community.
32
3.2 Getting Started with Fastify
Beyond its core capabilities, Fastify offers a robust ecosystem of tools and features that
make it a strong choice for building modern APIs. In this section, we’ll dive into some
key aspects of working with Fastify that elevate both development and application
performance.
From validating data with Ajv to simplifying test integration, handling configuration and
environment variables, managing graceful shutdowns, and utilizing high-performance
HTTP clients like Undici, Fastify provides a toolkit that caters to developers’ needs.
Additionally, its seamless database integrations and TypeScript (via TypeBox) support
ensure a streamlined workflow for building scalable and maintainable applications.
Essentially, Fastify’s Typebox Integration bridges the gap between development and
runtime data validation by ensuring that the data structures you define are consistent
and correct throughout your application.
Let’s take a closer look at these features and how they enhance the Fastify development
experience.
33
1. First, create a new directory for your project and initialize it:
mkdir fastify-typebox-demo
cd fastify-typebox-demo
npm init -y
{
“type”: “module”,
“scripts”: {
“start”: “node server.ts”
}
}
It’s important to note that this will work for Node.js V23+.
34
import { randomUUID } from ‘node:crypto’;
import Fastify from ‘fastify’;
import { Type, type TypeBoxTypeProvider } from ‘@fastify/type-
provider-typebox’
export async function buildServer({ port = 3000, host = ‘0.0.0.0’ } = {}) {
const fastify = Fastify({
logger: true
}).withTypeProvider<TypeBoxTypeProvider>();
// Define TypeBox schema for request validation
const User = Type.Object({
name: Type.String(),
email: Type.String({ format: ‘email’ }),
age: Type.Number({ minimum: 0 }),
preferences: Type.Optional(Type.Object({
newsletter: Type.Boolean(),
theme: Type.Union([
Type.Literal(‘light’), Type.Literal(‘dark’)
])
}))
});
// Route with TypeBox validation
fastify.post(‘/users’, {
schema: {
body: User
}
}, async (request, reply) => {
const user = request.body;
// TypeScript knows user’s shape thanks to TypeBox
return {
message: `Created user ${user.name}`,
userId: randomUUID()
};
});
// Start server with graceful shutdown
try {
await fastify.listen({ port, host });
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
return fastify;
}
35
3.3 Tests with node:test
Unit Testing
Focuses on individual functions or modules. In Fastify APIs, unit tests
validate isolated route handlers or utility functions without involving
other parts of the application.
Integration Testing
Ensures that different parts of the application work together correctly.
For an API, this means testing routes, middleware, and interactions
with databases or other services.
A well-tested API combines all three types of tests to achieve robust coverage. In this
chapter, we’ll cover how to implement unit and integration tests using Node.js’s built-in
node:test module to make your Fastify APIs enterprise-ready.
Overview of node:test
Starting with Node.js 18, the node:test module provides a straightforward, built-in
solution for writing and running tests. It offers a simple API to write both synchronous
and asynchronous tests, supports assertions, and integrates smoothly with JavaScript’s
assert module, making it a lightweight but powerful choice for API testing.
36
Key benefits of node:test
No Additional Dependencies
Since it’s built into Node.js, you don’t need any third-party libraries to
write and run basic tests.
Easy Integration
Works well with popular libraries like Fastify, making it easy to validate
API endpoints.
Flexible Assertions
The module supports a wide range of assertions, covering basic to
advanced validation needs.
Let’s start by setting up node:test for a Fastify project and writing a few initial tests.
1. G
enerate a Fastify project
Run the command to generate a Fastify application.
mkdir my-app
npm init fastify
npm i
37
2. Create a Test File
Create a tests folder and in it, create a new file named routes.test.js (or
another descriptive name). Add the following basic structure to start:
You can add more tests by adding their paths and filenames.
Alternatively you can run all tests in a directory directly from the command line:
38
This will automatically discover and run all files named *.test.js or *.spec.js in
your project.
In unit testing, we focus on testing individual route handlers. By isolating each handler,
we ensure that each function behaves correctly without depending on external
components or services.
This isolation is particularly useful for enterprise applications, where each handler may
have complex logic and dependencies.
Let’s say we have a more complex route that takes a name parameter and returns a
personalized greeting. Here’s how we can write a unit test for this handler:
This test verifies that our /greet/:name route correctly returns a personalized
message. By using node:test and Fastify’s inject method, we can test the route
handler in isolation without starting an actual server.
39
3.6 Integration Testing with Fastify and node:test
While unit tests validate individual functions, integration tests ensure that multiple
components work together.
Fastify’s inject method is particularly useful here, allowing us to simulate full HTTP
requests and validate responses.
For instance, if your Fastify application interacts with a database to retrieve user data,
you can set up integration tests to validate the entire flow:
await t.test(‘GET /user/:id returns the correct user data’, async (t) => {
const response = await fastify.inject({
method: ‘GET’,
url: ‘/user/123’
});
equal(response.statusCode, 200, ‘Expected status code 200’);
const body = await response.json();
deepEqual(body, {
id: ‘123’,
name: ‘Alice’
}, ‘Expected user data’);
});
});
This example is an integration test for retrieving user data, and here we are fetching
the user “Alice” and the respective “id.” Integration tests are powerful tools for
identifying issues that arise from interactions between different parts of your API.
40
Best Practices for Tests with node:test
Automate in CI/CD
Integrate node:test into your CI/CD pipeline to automatically run
tests with each commit. This ensures that new code doesn’t introduce
regressions.
41
Advanced Unit Testing Techniques
As your Fastify application grows, route handlers may include more complex logic
that requires additional testing techniques. Here are a few advanced techniques for
more robust unit testing.
In production, your API will encounter unexpected inputs and failure conditions. It’s
essential to test edge cases and confirm that errors are properly handled and logged.
1. E
rror Handling in Fastify Routes
Imagine a route that fails if required data is missing. Testing this error scenario
helps verify that your error responses are clear and informative.
42
In this test, a missing number parameter triggers an error, and the response is
checked to confirm it’s handled gracefully.
End-to-end (E2E) tests simulate real user flows, verifying that all parts of the system
work together seamlessly. For Fastify, E2E tests might include calling multiple
endpoints in sequence to simulate workflows. To set up E2E tests effectively,
consider running a separate test database or using Docker to simulate production
conditions.
In this E2E test, a user signs up, and then their profile is retrieved. These types of
tests verify that multi-step workflows function as expected.
43
Performance Testing in Node.js Applications
1. B
asic Performance Benchmark
First, add server as shown below:
console.log(autocannon.printResult(result));
}
runBenchmark();
44
Running this test produces detailed performance metrics:
This tests the response time for the /heavy-route endpoint, ensuring it meets
performance expectations, and in this case a response time under 2.58ms.
Testing is most effective when automated. Integrate node:test into your CI/CD
pipeline to run tests on every commit, making sure new changes don’t introduce errors.
1. S
etting Up GitHub Actions for node:test
name: Tests CI
on:
push:
branches:
- main
jobs:
test:
runs - on: ubuntu - latest
steps:
- uses: actions / checkout@v2
- uses: actions / setup - node@v2
with:
node - version: ‘22’
- run: npm install
- run: npm test
With this GitHub Actions workflow, tests run automatically on every push to the main
branch, ensuring code quality and reliability.
45
3.7 Config Handling and Environment Variables
Environment variables help keep sensitive information and application settings secure,
preventing credentials or secrets from being hard-coded directly into your application. By
loading configurations at runtime, environment variables enable seamless deployments
across various stages of your application lifecycle.
Environment isolation
Each environment (development, staging, production) can have its own
variables.
Enhanced security
Secrets are kept outside the codebase.
Better scalability
Adjust settings quickly without code modifications.
A .env file is a simple way to manage environment variables locally. Each line represents
a key-value pair that the application loads at runtime. Here’s an example of a .env file:
PORT=3000
DATABASE_URL=postgres://username:password@localhost:5432/mydatabase
JWT_SECRET=mySuperSecretKey
API_KEY=abc123def456
46
Using .env file for Config Management
console.log(process.env.PORT);
Run with:
With this, you can run the application with your project environment variables without
hard-coding the values.
While .env files are helpful in development, they should be avoided in production
environments. Instead, use your platform’s secret management tools. Here are some
ways to protect sensitive data in production:
47
Environment-Specific Configurations
While it’s common to see NODE_ENV used for environment-specific configurations, this
approach has serious drawbacks:
1. N
ODE_ENV should only be used for what it was designed for: telling Node.js
and frameworks whether we’re in production mode (enabling optimizations) or
development mode (enabling debugging features).
const config = {
database: {
url: process.env.DATABASE_URL,
pool: parseInt(process.env.DATABASE_POOL_SIZE, 10)
},
logging: {
level: process.env.LOG_LEVEL
},
};
# Production
DATABASE_URL=postgres://prod LOG_LEVEL=error node app.js
# Development
DATABASE_URL=postgres://dev LOG_LEVEL=debug node app.js
48
Configuring with env-schema and Typebox
49
Best Practices for Config Management in Production
const config = {
port: process.env.PORT || 3000,
db: {
url: process.env.DATABASE_URL || ‘postgres://localhost:5432/defaultdb’,
user: process.env.DB_USER || ‘defaultUser’,
password: process.env.DB_PASS || ‘defaultPass’,
},
};
50
3.8 Graceful Shutdowns with close-with-grace
Enterprise applications often deal with high volumes of concurrent requests, database
connections, and other resources that must be managed responsibly.
Without proper shutdown handling, abrupt terminations can lead to issues such as:
Resource leakage
Unreleased resources like database connections or file handles can
lead to memory leaks or bottlenecks.
It intercepts termination signals and allows you to register cleanup logic that runs
before the process exits, ensuring everything is neatly closed down.
51
Some features of close-with-grace:
Asynchronous support
for tasks such as database disconnections or closing HTTP servers.
Customizable timeout
to specify how long to wait for all cleanup tasks to complete.
1. Install close-with-grace:
52
// Register the close-with-grace handler
closeWithGrace(async ({
signal
}) => {
fastify.log.info(`Received ${signal}. Closing application...`);
await fastify.close();
fastify.log.info(‘Application closed.’);
});
3. T
est the graceful shutdown by sending termination signals (e.g., Ctrl+C in your
terminal) and observing the application’s behavior.
53
Managing Asynchronous Tasks on Shutdown
In some cases, an application may have ongoing asynchronous tasks that need to
complete before the process exits. Here’s how to handle these tasks effectively:
console.log(‘Cleanup completed.’);
});
Timeouts: You can set an optional timeout to enforce an exit if tasks take too long.
await Promise.race([
myAsyncTask1(),
myAsyncTask2(),
wait(5_000)
]);
54
Testing Graceful Shutdowns
While testing graceful shutdowns can be challenging, we can effectively test our
shutdown logic by focusing on server cleanup and in-flight request handling:
55
test(‘Should handle graceful shutdown’, async () => {
process.emit(‘SIGINT’);
await someCleanupTask(); // Simulated task
expect(someResource).toBe(null); // Check if resources are
released
});
56
3. 9 Clients with Undici
High performance
Optimized for speed and efficiency.
Promise-based API
Simplifies asynchronous code with modern JavaScript syntax.
These benefits make Undici an ideal choice for enterprise applications requiring efficient
and fast HTTP connections, especially those handling a high volume of requests.
Setting Up Undici
1. Installation
To start, install Undici via npm:
2. Basic Import
Import Undici into your project and set up a basic client:
import { request } from ‘undici’;
3. Environment Compatibility
Since Undici leverages modern JavaScript features, ensure your Node.js version is
14 or above for compatibility.
57
Making Basic HTTP Requests
With Undici, making HTTP requests is both efficient and straightforward. Here’s an
example of a simple GET request to fetch data from an external API.
console.log(‘Status:’, statusCode);
console.log(‘Headers:’, headers);
fetchUserData();
Undici supports various HTTP methods (GET, POST, PUT, DELETE, etc.) and request
options, making it flexible for advanced use cases. Below are some common
configurations:
58
2. Adding Authentication Headers
fetchProtectedData();
Managing Responses
Undici provides direct access to the response status code, headers, and body,
allowing fine-grained control over responses. Use .body.json() or .body.text()
based on the response type.
59
Integrating Undici with Fastify
Fastify, a powerful web framework for Node.js, can be seamlessly integrated with
Undici. This allows for efficient HTTP requests within Fastify routes.
When integrating Undici with Fastify, handle errors properly to maintain smooth
request handling.
60
Reuse connections
Utilize HTTP keep-alive connections for reduced overhead in high-
concurrency environments.
fetchDataFromPool();
Undici does not include built-in retry mechanisms, so handling errors and retries is
crucial, particularly for enterprise-grade applications.
import {
request,
getGlobalDispatcher,
interceptors
} from ‘undici’;
61
3.10 Connecting to a database
Selecting the appropriate database type is crucial. Here are the main categories
commonly used in enterprise applications:
NoSQL Databases
MongoDB, Cassandra, and Redis are suitable for applications needing
flexibility with data structure and high scalability.
In-Memory Databases
Redis and Memcached are popular choices for caching and managing
real-time data.
62
Considerations:
Scalability
Does the database support horizontal scaling?
Complexity
Are relational data and complex querying required?
For this section, we’ll demonstrate using PostgreSQL (a SQL database) and MongoDB
(a NoSQL database).
npm install pg
3. Optional: If using an ORM or query builder like Prisma or Knex, install it as well.
63
Connecting to a SQL Database (PostgreSQL Example)
1. C
onfiguring the Database:
Set up a PostgreSQL database and ensure you have the following credentials
ready:
• Database URL
• Username
• Password
connectToDatabase();
64
3. Basic Query Example:
fetchUsers();
1. S
etting Up MongoDB URI:
For local MongoDB setup:
MONGO_URI=mongodb://localhost:27017/yourDatabase
connectToMongoDB();
65
3. Fetching Data from MongoDB:
fetchUsers();
DB_USER=yourUser
DB_HOST=localhost
DB_NAME=yourDatabase
DB_PASSWORD=yourPassword
DB_PORT=5432
MONGO_URI=mongodb://localhost:27017/yourDatabase
For enterprise-grade applications, using ORMs or query builders helps with data
abstraction, allowing you to manage models without raw SQL.
1. Using Prisma:
// schema.prisma
model User {
id Int @id @default(autoincrement())
name String
email String @unique
}
66
npx prisma generate
getAllUsers();
67
Integrating Databases with Fastify
Fastify makes it easy to add database integrations by decorating the Fastify instance
with your database client. Here’s an example using PostgreSQL:
try {
await fastify.listen({
port: 3000
});
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
Most SQL drivers, including PostgreSQL and MongoDB, support connection pooling,
which optimizes resource use and improves response time in high-traffic applications.
Here’s how to set up connection pooling for PostgreSQL:
68
import Fastify from “fastify”;
import postgres from “@fastify/postgres”;
fastify.register(postgres, {
connectionString: “postgres://localhost@localhost:5432/product_db”,
});
return fastify;
}
69
Best Practices for Secure and Resilient Connections
In enterprise Node.js applications, reliable error handling and logging are crucial. A
robust error-handling and logging system ensures that issues are identified quickly,
logs are comprehensible, and system behavior is traceable.
70
Improves User Experience
Users receive clear, actionable error messages.
Aids Development
Developers can identify and resolve issues faster with meaningful error
logs.
Increases Resilience
The system can handle unexpected failures gracefully, often without
crashing.
Enhances Security
By avoiding overly detailed error messages for users, sensitive
information remains secure.
Fastify provides a built-in mechanism for handling errors with customized handlers.
This example demonstrates how to set up a global error handler to respond
consistently to client requests while capturing errors for logging.
reply.status(statusCode).send(response);
});
71
Explanation:
The setErrorHandler method ensures that all uncaught errors are intercepted and
processed, avoiding crashes and providing controlled responses.
Fastify allows specific error handling per route, which is useful for routes requiring
specialized responses:
Operational Errors
Known issues like database connection failures, file not found, etc.
Programming Errors
Bugs in code (e.g., undefined variables).
System Errors
Unexpected system-related issues, such as memory overflow.
72
Providing Meaningful Error Messages for Users and Developers
User-Facing Messages
• Simplify messages and provide general guidance, like “An error
occurred, please try again.”
• Avoid exposing implementation details or stack traces.
Developer-Facing Messages
• Include details about the error location, the type of error, and
related request information (headers, body).
• Stack traces can be captured with higher logging levels but hidden
from end users.
Fastify uses Pino as its default logger, known for its speed and structured logging
capabilities. Here’s how to configure and optimize Pino for an enterprise environment:
1. Basic Configuration:
73
2. Logging Middleware for Route-Specific Logs:
74
Error Monitoring and Logging Aggregation Tools
For robust error handling and logging, use monitoring and aggregation tools that work
well with Pino and Fastify.
Sentry
Captures and analyzes errors with stack traces and user context.
DataDog
Provides logging and monitoring across services, with customizable
dashboards.
LogDNA
Aggregates logs and supports Pino integration for structured log
analysis.
Here’s how to integrate Sentry with Fastify for additional error tracking:
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 1.0, // Capture all transactions
});
75
3.12 TypeScript
For complex enterprise APIs, TypeScript helps avoid runtime errors by catching issues
at compile time and providing clear interfaces, reducing ambiguities that are common
in large codebases.
76
Setting Up TypeScript with Fastify
To begin using TypeScript in your Fastify application, you’ll need to install the necessary
dependencies and configure TypeScript for the project:
1. Install Dependencies:
{
“extends”: “@fastify/tsconfig”,
“compilerOptions”: {
“outDir”: “dist”,
“sourceMap”: true
},
“include”: [“src/**/*.ts”]
}
try {
await app.listen({
port: 3000,
});
} catch (err) {
app.log.error(err);
process.exit(1);
}01
77
Defining Types for Requests and Responses
TypeScript allows us to define clear types for request parameters, query strings,
request bodies, and responses. Here’s how to define types for a route:
interface GetUserRequest {
Params: {
userId: string;
};
}
Using these types ensures that any mismatches in request structure or data type are
caught during development.
When building APIs, it’s common to have shared data structures (e.g., User, Product).
Use TypeScript interfaces and type aliases to define these structures and reuse them
across the project:
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
These interfaces can be shared across modules, ensuring consistency and reusability.
78
3.13 Type-Safe Configuration and Environment Variables
Managing environment variables with type safety enhances reliability. To ensure type
safety, define the expected configuration using TypeScript:
interface Config {
PORT: number;
DATABASE_URL: string;
NODE_ENV: ‘development’ | ‘production’ | ‘test’;
}
2. V
alidate the Configuration:
Use a library like zod to validate the configuration values at runtime.
Error handling in TypeScript offers the advantage of defining custom error types that
can be caught and managed systematically. Define a custom error type to handle
known cases:
try {
// logic
} catch (error) {
if (error instanceof NotFoundError) {
reply.status(404).send({ message: error.message });
}
}
Custom error types make your error handling more structured and meaningful.
79
3.15 Type-Driven Validation with Typebox
Typebox enables schema definitions that work well with TypeScript, offering runtime
validation and static type generation. Here’s how to integrate it:
80
Leverage Typebox
Use Typebox for both runtime validation and TypeScript typing.
Avoid “any”
Avoid the “any” type to maintain type safety and rely on unknown if
type is unclear.
When defining schemas using TypeBox, you’re actually creating JSON Schemas that
Fastify uses to validate incoming data with Ajv at runtime.
Testing your route handlers ensures that these JSON Schema validations work as
expected—rejecting incorrect input and allowing valid data.
fastify.post(‘/user’, {
schema: {
body: UserSchema
}
}, async (request, reply) => {
return {
message: `User ${request.body.name} created.`
};
});
}
81
Now, when you need to run your server or test your routes, you can import and invoke
buildFastify to get a new instance:
assert.equal(response.statusCode, 200);
});
Here, Typebox ensures that incorrect data (like a numeric name value) triggers a 400
Bad Request. This test confirms that data validation works as expected, even in edge
cases.
82
1. M
ocking External Services for Integration Tests
Suppose your Fastify route fetches user data from an external API:
test(“GET /external-data - returns data from external API”, async (t) => {
const fastify = buildServer();
assert.equal(response.statusCode, 200);
assert.deepEqual(await response.json(), { id: “abc”, value: “Test
Data” });
await fastify.close();
});
Using undici, you can intercept and simulate the external API’s response, ensuring
reliable integration tests without actual network requests.
83
3.16 Introduction to Data validation
In API development, ensuring that the incoming data is structured and valid is essential
for security, reliability, and maintainability. Ajv is a fast JSON Schema validator that
provides robust runtime validation of data. Leveraging JSON Schema offers several
key advantages:
Comprehensive Validation
Ajv can enforce a broad range of constraints—types, formats, and
custom validations—ensuring that every piece of data entering your
system conforms to predefined rules. This reduces the risks of security
vulnerabilities such as injection attacks and minimizes the chance of
data corruption.
Performance
Built for speed, Ajv provides high-performance validation even under
demanding workloads, which is crucial for scalable, enterprise-level
systems.
By validating incoming data with Ajv, you establish a robust first line of defense, ensuring
that your API handles only well-structured and expected data formats.
84
Type Safety with TypeBox
While Ajv ensures runtime data integrity, TypeBox complements this by providing static
type safety during development. TypeBox allows you to define JSON Schemas using
TypeScript syntax, which brings several advantages:
Type Providers
With TypeBox, you can generate schemas that serve as both runtime
validators and compile-time type definitions. This dual role helps
prevent mismatches between the types your application expects and
the validation logic.
Using TypeBox together with Ajv creates a powerful combination: robust runtime
validation through JSON Schema standards and seamless type safety with TypeScript.
This integrated approach ensures that your API not only enforces data integrity at
runtime but also benefits from enhanced developer productivity and code reliability
during development.
85
Why Data Validation is Critical in Enterprise Applications
For enterprise applications, data validation is more than just preventing bad inputs; it is
a foundational element for:
Ensuring Consistency
Large-scale applications often integrate multiple services, databases,
and external clients. Validation ensures that the data exchanged
between these systems remains consistent and predictable.
Enhancing Security
Strong data validation limits the attack surface by ensuring that only
data conforming to predefined schemas enters your system, preventing
exploits like injection attacks.
What is Typebox?
Typebox is a powerful library for defining and validating JSON schemas in TypeScript.
It helps developers define complex data structures, such as objects, arrays, and
nested objects, in a way that can be validated at runtime and statically checked during
development.
Type-Safe Validation
Typebox allows developers to define JSON schemas while also
automatically inferring TypeScript types from those schemas.
86
Fastify Integration
It seamlessly integrates with Fastify’s built-in validation mechanisms,
enabling you to apply schema validation to your API routes.
Schema Composition
Typebox allows you to create complex schemas using basic building
blocks like Type.Object, Type.String, and Type.Array.
A schema defines the structure and rules for the data your API expects. Typebox
provides several built-in types that allow you to construct these schemas easily. Below
are some examples:
// You can create more complex schemas with nested objects or arrays
const ProductSchema = Type.Object({
title: Type.String(),
price: Type.Number({ minimum: 0 }),
tags: Type.Array(Type.String()), // array of strings for tags
});
Here, Type.Object creates a schema for objects, while Type.String() and Type.
Number() ensure that fields match the specified types. The schema also allows
additional options like minLength or minimum to enforce further validation rules.
87
Validating API Requests
Once you’ve defined your schemas, integrating them with Fastify’s route validation is
simple. Fastify uses these schemas to validate incoming requests, ensuring that the
data adheres to the rules you’ve set up.
For example, if you want to validate the payload of a POST /users request, you can do
so like this:
If a client sends invalid data to this route, Fastify will automatically return a 400 Bad
Request response, ensuring that only valid data is processed by your application.
88
Type Inference with TypeScript
One of Typebox’s most valuable features is its ability to infer TypeScript types from the
schemas you define.
This allows you to enjoy the benefits of both runtime validation and compile-time type
safety:
// Now you can use the ‘User’ type throughout your code
function createUser(user: User) {
console.log(`Creating user: ${user.name}`);
}
The Static utility from Typebox automatically generates TypeScript types based on
your schema, giving you strict type-checking while writing your application.
Fastify not only validates data but also provides helpful error responses. For example,
if a required field is missing or a type mismatch occurs, Fastify sends a detailed error
message back to the client. Here’s how you can customize error responses:
This way, you can ensure that clients receive meaningful feedback when they provide
invalid data, improving the overall API experience.
89
Best Practices for Data Validation in Enterprise Systems
As your application scales, so does the complexity of your data validation requirements.
Here are a few best practices to consider:
const schema = {
body: {
type: ‘object’,
properties: {
username: {
type: ‘string’
}, // Required field
middleName: {
type: ‘string’
}, // Optional field
phoneNumber: {
type: [‘string’, ‘null’]
}, // Optional, can be string or null
role: {
type: ‘string’,
default: ‘user’
}, // Optional, defaults to “user”
age: {
type: [‘number’, ‘null’]
} // Optional, can be number or null
},
required: [‘username’] // Only “username” is mandatory
}
};
Versioning Schemas
When updating API schemas, ensure backward compatibility by
versioning your APIs or allowing legacy schemas.
90
Wrapping Up
Building robust, scalable, and high-performance APIs with Fastify involves much more
than just setting up routes. With its extensive plugin ecosystem, support for TypeScript,
powerful validation capabilities via TypeBox and Ajv, and built-in testing tools, Fastify
makes it easier to craft modern, enterprise-grade applications.
The integration with tools like Undici and the ease of connecting to various databases,
alongside best practices for graceful shutdowns, error handling, and configuration
management, ensures that your APIs are not only fast but also reliable and secure.
Whether you’re managing data flow, handling high concurrency, or preparing your app
for the challenges of real-world traffic, Fastify provides the foundation for building
efficient, maintainable, and performant APIs.
By leveraging the right testing strategies and performance optimisations, you can
ensure your Fastify APIs will scale to meet the demands of enterprise environments
with confidence.
At Platformatic, we believe that Open Source projects like Fastify are the backbone
of innovation. As a proud sponsor and active contributor to the Fastify project,
Platformatic is able to give back directly to the tools we rely on, ensuring that they
remain sustainable, secure, and future-proof.
These projects are critical to the developer community and the broader tech industry,
enabling teams to build and scale applications with greater efficiency. Platformatic
itself was born out of the desire for us to build upon the technical foundations of Fastify.
Today, Platformatic seamlessly integrates with the Fastify web framework and extends
its capabilities while incorporating all the expertise in building, operating, and scaling
Node.js applications that we have accumulated over the past 10 years from the Open
Source Community.
91
4
Building SSR
Frontends
SSR frameworks, a guide to building
a basic SSR page, deployment
considerations, and more
1 Building SSR Frontends 94
One of the primary benefits of SSR is its impact on Search Engine Optimization (SEO).
Search engine crawlers, which often struggle with JavaScript-heavy client-side
applications, can easily parse and index the pre-rendered HTML provided by SSR.
Additionally, SSR improves initial load times since the browser does not need to wait
for JavaScript to execute before displaying meaningful content. This, in turn, leads to
an enhanced user experience, particularly for users on slow networks or devices with
limited processing power.
Node.js is particularly well-suited for SSR due to its ability to handle server-side tasks
efficiently. With its asynchronous, event-driven architecture and robust JavaScript
runtime, Node.js excels at fetching data from APIs and dynamically generating HTML
content. It also integrates seamlessly with frameworks like React, making it a strong
choice for developers looking to implement SSR in modern web applications.
By combining the strengths of Node.js and SSR, developers can create applications
that are not only performant but also deliver content-rich experiences to users across
various environments, reinforcing the importance of SSR in today’s web ecosystem.
94
Additionally, developers must consider caching strategies, load balancing, and efficient
data fetching to ensure scalability and performance. Proper error handling and fallback
mechanisms are also critical, as server-side rendering can introduce complexities in
managing client-server interactions.
To simplify the implementation of SSR, several frameworks built on top of Node.js have
emerged.
These frameworks provide tools and abstractions that streamline the development
process, allowing developers to focus on building features rather than configuring SSR
from scratch.
Next.js is one of the most popular frameworks for SSR with React according to the
State of JavaScript 2024.
Each file in the pages directory corresponds to a route, and developers can use Next.
js’s getServerSideProps function to fetch data and render it server-side.
Ease of setup
Next.js handles most of the heavy lifting, such as bundler configuration
and optimization.
95
Static site generation (SSG)
In addition to Server Side Rendering (SSR), Next.js supports static
generation, offering a hybrid approach to rendering. With Incremental
Static Regeneration (ISR), developers can update static content after
deployment, ensuring a balance between the performance benefits of
SSG and the flexibility of SSR for dynamic updates.
API routes
Developers can create serverless API endpoints directly within the
application, which can be convenient for small-scale projects or
prototypes. However, for larger applications, dedicated API frameworks
are often a better choice, as they provide robust features like validation,
OpenAPI support, and enhanced scalability.
While Next.js provides a full-fledged framework, Vite is a lightweight build tool designed
for speed and simplicity. It excels at fast development builds and offers some SSR
capabilities when combined with additional libraries.
Philosophy
Next.js is opinionated and includes a comprehensive set of tools
for building SSR applications, while Vite emphasizes flexibility and
performance as a build tool.
Setup complexity
Vite requires additional configuration and integration with libraries to
achieve SSR, whereas Next.js provides a more out-of-the-box solution.
For most projects prioritizing SSR, Next.js stands out as the go-to choice, offering a
robust ecosystem and extensive documentation to accelerate development.
96
Setting Up a Demo Project
In this chapter, we’ll create an event listing application using Next.js 13+ with App
Router.
This project will demonstrate server-side rendering (SSR), dynamic routing, and modern
React features like Streaming and Suspense.
Project Overview
Our demo project is an events platform that allows users to:
Initial Setup
First, create a new Next.js project with the following configuration:
97
Project Structure
Dependencies
98
Initial Component Setup
Let’s create our first component to verify the setup. Replace the content of app/page.
js with:
99
4.2 Building a Basic SSR Page
Now, we’ll create our first server-side rendered page using Next.js. We’ll build an events
listing page that will showcase the power of SSR for improved performance and SEO.
You can find all code referenced in this book in the following Github repository:
https://github.com/platformatic/events-app-ebook
Before diving into the code, let’s understand why SSR is beneficial for our events
platform example. It offers:
• Better SEO as search engines can crawl the fully rendered content
• Faster initial page load and First Contentful Paint (FCP)
• Improved performance on slower devices
• Better social media sharing with proper meta tags
In this section, we’ll build an SSR-powered page to display a list of events in app/
events/page.js.
• title: Specifies the title that appears in the browser tab and improves SEO.
• description: Provides a meta description for search engines, enhancing
discoverability.
100
2. Defining the SSR Page Component
The EventsPage component is the default export for this file. It’s marked as async to
enable server-side data fetching for all upcoming events.
Create app/components/events/event-card.js:
101
export default function EventCard({ event }) {
return (
<Link
href={`/events/${event.id}`}
className=”group block overflow-hidden rounded-lg border bg-
white shadow-sm”
>
<div className=”relative h-48 w-full”>
<Image
src={event.imageUrl || ‘/images/placeholder.jpg’}
alt={event.title}
fill
className=”object-cover”
sizes=”(max-width: 768px) 100vw, (max-width: 1200px) 50vw,
33vw”
/>
</div>
<div className=”p-4”>
<h3 className=”text-lg font-semibold group-hover:text-
blue-600”>
{event.title}
</h3>
The fill property ensures the image fully covers its container, while the sizes
attribute optimizes loading based on screen width.
The event title and details, including the formatted event date and location, are
displayed inside a styled container.
102
Server-Side Data Fetching
Create app/lib/api/events.js:
103
Loading State with Suspense
Suspense is a React feature that lets you display a fallback until your children have
finished loading for user interfaces.
104
Benefits of This Implementation
SEO Optimization
• Content is rendered on the server
• Search engines receive complete HTML
• Meta tags are properly set
Performance
• Fast initial page load
• Minimal client-side JavaScript
• Efficient image loading with next/image
User Experience
• No content flashing
• Smooth loading states
• Progressive enhancement
Maintainability
• Clean component structure
• Separated concerns
• Reusable components
Network Impact
• Larger initial HTML payload
• Multiple roundtrips for data fetching
• Additional bandwidth usage for loading states
105
Fea Fea
tur tur
e - e -
1 2
Fea
tur
e -
1
Fea Fea
tur tur
e - e -
2 3
Now, we’ll enhance our events platform with additional features that leverage Next.js’
powerful capabilities. We’ll implement dynamic routing, optimize performance, and add
modern React features.
Next.js provides automatic routing based on the file system. Let’s create a dynamic
route for individual event pages.
return (
<Suspense fallback= {<EventDetailSkeleton />}>
<EventDetail event={ event } />
</Suspense>
);
}
106
export async function getEventById(id) {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/db/events/${id}`,
{ next: { revalidate: 60 } }
);
if (!response.ok) {
throw new Error(‘Event not found’);
}
return response.json();
} catch (error) {
console.error(‘Error fetching event:’, error);
throw error;
}
}
Image Optimization
107
Implementing Prefetching
108
Create loading UI components:
// app/components/loading/loading-states.js
export function EventListSkeleton() {
return (
<div className=”grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3
gap-6”>
{[1, 2, 3].map((n) => (
<div key={n} className=”animate-pulse”>
<div className=”h-48 bg-gray-200 rounded-t-lg” />
<div className=”p-4 space-y-3”>
<div className=”h-6 bg-gray-200 rounded w-3/4” />
<div className=”h-4 bg-gray-200 rounded w-1/2” />
</div>
</div>
))}
</div>
);
}
Error Handling
// app/error.js
‘use client’;
export default function Error({ error, reset }) {
return (
<div className=“text-center py-12” >
<h2 className=”text-2xl font-bold text-red-600 mb-4” >
Something went wrong!
</h2>
<button onClick={() => reset() }
className=“bg-blue-600 text-white px-4 py-2 rounded”>
Try again
</button>
</div>
)
109
Performance Optimizations
// app/events/[id]/page.js
export async function generateMetadata({ params }) {
const event = await getEventById(params.id);
return {
title: `${event.title} | Event Listing App`,
description: event.description.slice(0, 160),
openGraph: {
title: event.title,
description: event.description,
images: [event.imageUrl],
},
};
}
110
Dep
loy
men
tes t C
tin
g ons
ider
ati
ons
sta
gin
g
Now let’s explore different deployment strategies for both Next.js and Vite-based React
applications, along with essential caching considerations for optimal performance.
When using Next.js ISR in enterprise environments, one common concern is that
Next.js may perform fetch calls during the build. This can create environment-specific
artifacts if you’re promoting the same build output from, say, staging to production.
If your build fetches data from environment-specific endpoints, the generated static
files will be “locked” to data from that environment.
• Disable or limit ISR calls at build time if you plan to re-use the same bundle across
multiple environments.
• Use runtime environment variables for data fetching instead of doing fetch calls at
build time.
• Configure revalidation logic to happen at runtime, so your app can update data after
deployment without a rebuild.
111
Deployment Options for Next.js
1. V
ercel (Recommended for Next.js)
Vercel, created by the Next.js team, offers the most streamlined deployment experience:
# Deploy
vercel
Benefits:
• Zero-configuration deployment
• Automatic CI/CD pipeline
• Built-in edge caching
• Serverless functions support
• Automatic HTTPS
• Preview deployments for branches
2. AWS Amplify
AWS Amplify provides a robust platform for Next.js applications:
# amplify.yml
version: 1
frontend:
phases:
build:
commands:
- npm ci
- npm run build
artifacts:
baseDirectory: .next
files:
- ‘**/*’
cache:
paths:
- node_modules/**/ *
112
Benefits:
• AWS service integration
• Global CDN
• Easy scaling
• Managed database options
// server.js
const { createServer } = require(‘http’)
const { parse } = require(‘url’)
const next = require(‘next’)
app.prepare().then(() => {
createServer((req, res) => {
const parsedUrl = parse(req.url, true)
handle(req, res, parsedUrl)
}).listen(3000)
})
113
Handling ISR and Caching
Revalidation
You’ll need to explicitly route calls to Next.js’ revalidation endpoints to
regenerate static pages.
Cache Headers
If you’re controlling the server layer, you’re also responsible for configuring
cache headers (Cache-Control, ETag, etc.) to ensure stale pages are
properly revalidated or invalidated.
On-Demand Revalidation
Many teams use a secure API route in Next.js to trigger on-demand
revalidation (e.g., when CMS content is updated). Make sure your server
forwards those requests properly to Next.js.
Developers looking to deploy Next.js on AWS without major rewrites can leverage
OpenNext to achieve serverless scaling, cost efficiency, and better performance—all
while maintaining full compatibility with the Next.js framework.
114
Key Components for Non-Vercel Deployment
To successfully run Next.js outside of Vercel, several critical areas must be addressed:
115
4.5 Understanding HTTP caching
In HTTP, servers specify if a resource can be cached from the clients using the Cache-
Control header. Additionally, the ETag, Last-Modified and Vary headers provide
information about the content and are used by the client to respect the policy specified
in the Cache-Control header.
The Cache-Control header defines policies for private and shared caches.
116
The ETag header is used to send the client the unique identifier of the resources being
returned. When the resource changes its ETag is guaranteed to change. The ETag is
generated by the server and its generation technique is unknown and needed by the
client.
The Vary hearer is used to send the client the request headers, if any, that concurred to
the generation of the returned resource. This means that the client should consider the
request headers specified in the Vary response header to cache the resource. Different
request headers (which are included in the `Vary` header) for the same resource will
result in different entries in the client cache.
Next.js enables automatic cache revalidation based on a specified time interval. You
can set the revalidate property to define how often the page should be regenerated.
return <div> {
JSON.stringify(data)
} </div>;
}
117
Here, the page fetches external data and caches it for one hour (3600 seconds).
After that, the cache is automatically invalidated, and a new request is made in the
background while serving the stale content until the updated page is ready.
function Profile() {
const { data } = useSWR(‘/api/user’, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 3_600_000
});
return <div>{
data?.name
} < /div>;
}
Using libraries for caching data in memory has benefits such as instant access from
memory, automatic revalidation, and request deduplication.
118
Browser-Level HTTP Caching
// next.config.js
module.exports = {
async headers() {
return [
{
source: ‘/api/:path*’,
headers: [
{
key: ‘Cache-Control’,
value: ‘private, max-age=3600’
}
]
}
]
}
}
Using the browser approach with Next.js ISR persists across page reloads, works with
CDN caching, and reduces network requests.
ISR lets you create or update static pages after you build your Next.js application
without requiring a full rebuild. This is especially powerful for pages that don’t change
frequently but still need to be periodically updated
// pages/events/[id].js
export async function getStaticProps({ params }) {
return {
props: {
event: await getEvent(params.id)
},
revalidate: 60 // Regenerate page every 60 seconds
}
}
119
Why Use It?
Performance
Pre-renders pages are static files for fast delivery.
Fresh Data
Automatically revalidates and regenerates pages at runtime, meaning
you don’t need a new build for each content update.
How It Works
Initial Request
When a user visits a statically generated page, Next.js serves a pre-
rendered HTML.
Revalidation Timer
After revalidate seconds, the next request triggers Next.js to rebuild
that page in the background.
Automatic Update
Subsequent visitors see the updated page instantly once regeneration
completes.
Key Considerations
On-Demand Revalidation
You can also trigger re-validation programmatically (e.g., from a CMS
webhook) by hitting a special API route.
Caching Behavior
Even though the HTML file is static, if you’re self-hosting, you must
ensure your server or CDN handles revalidation logic correctly. Platforms
like Vercel handle this automatically.
120
Client-Side Caching
Client-side caching stores and reuses data within the user’s browser or application
state. React Query, SWR, or Apollo Client can manage fetch requests, cache responses,
and keep data in sync.
Instant UI Updates
Returns cached data immediately while revalidating in the background.
function useEvents() {
return useQuery(‘events’, fetchEvents, {
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
cacheTime: 30 * 60 * 1000 // Keep unused data for 30 minutes
})
}
staleTime
The period during which data is considered “fresh.” If a user re-requests
the data within this window, no new fetch happens.
cacheTime
How long unused data remains in memory or storage before being
garbage-collected.
121
Key Considerations
Choosing a Library
React Query, SWR, and Apollo Client each have caching strategies. Pick
the one that fits your data-fetching pattern (REST vs. GraphQL, etc.).
Cache Invalidation
Manually or automatically fetch data when it becomes outdated (e.g.
after a user updates an event).
Prefetching
Load data before a component mounts to avoid long loading states.
Before deploying your Next.js application to production, it’s crucial to implement both
performance optimizations and security measures.
This checklist covers essential configurations to ensure your application runs efficiently
and securely in production.
1. E
nable compression
Next.js uses gzip compression by default for static files, but you can enhance this
at the application level:
// next.config.js
module.exports = {
compress: true, // Enable gzip compression
experimental: {
// Enable modern compression techniques
compression: true,
// Enable modern image formats
modern: true
}
}
122
2. Enable a custom ISR cache handler for shared caching
Next.js supports custom cache ISR handlers. This is ideal to build shared caches
that can be shared between multiple instances of the same application. This will
bring massive performance gains since the default implementation is based on the
local filesystem, which is not shared between instances.
To use a custom handler, simply provide the file to the configuration file:
// next.config.js
module.exports = {
cacheHandler: require.resolve(‘./cache-handler.js’),
cacheMaxMemorySize: 0, // disable default in-memory caching
}
The full documentation to build a ISR cache handler can be found in the Next.js
documentation. Platformatic provides a Valkey based Next.js cache handler, which
can be found here.
3. Image Optimization:
Next.js provides built-in image optimization through the Image component. This
automatically handles:
123
4. A
ssets optimization
Optimize your static assets by configuring proper caching and minimization:
/ next.config.js
module.exports = {
// Enable static asset optimization
optimizeFonts: true,
// Configure asset prefix for CDN
assetPrefix:
process.env.NODE_ENV === ‘production’
? ‘https://cdn.example.com’
: ‘’,
// Minimize CSS
webpack: (config) => {
config.optimization.minimizer.push(new CssMinimizerPlugin())
return config
}
}
5. U
se production builds
Create optimized production builds with features like:
• Code splitting
• Package bundling
124
4.7 Automatic integration of a Next.js into Watt
The integration seamlessly integrates with your Next.js without any modification on
your side and allows you to to improve performance of your website by using a fast
shared cache for all your pages and the remote resources they might fetch while
performing SSR.
To move your existing application to Platformatic, let’s transform it into a Watt application.
mkdir new-app
cd new-app
npx wattpm@latest init
mv ../old-app web/app
{
“$schema”: “https://schemas.platformatic.dev/@platformatic/
next/2.44.0.json”,
“cache”: {
“adapter”: “valkey”, // “redis” will also work
“url”: “valkey://{CACHE_SERVER_URL}”,
“prefix”: “watt-valkey”
}
}
125
4. Create a .env file which sets the CACHE_SERVER_URL variable to the actual URL of
your Valkey/Redis server. Example:
CACHE_SERVER_URL=localhost:6379
You are all set! When starting, Watt will automatically instrument your existing Next.js
application to use its ISR cache handler.
Security Considerations
It’s important to implement security measures. and security measures. This checklist
covers essential configurations to ensure your application runs securely in production.
1. S
ecurity headers
Security headers are part of the HTTP response sent by your server to a browser.
They act as rulesets, dictating:
Setting security headers in your browser handle your site’s content and communicate
with servers. You can set essential security headers:
// next.config.js
/** @type {import(‘next’).NextConfig} */
module.exports = {
async headers() {
return [
{
source: ‘/ about’,
headers: [
{
key: ‘x-custom-header’,
value: ‘my custom header value’
},
{
key: ‘x-another-custom-header’,
value: ‘my other custom header value’
}
]
}
]
}
}
126
2. Configure CORS
Cross Origin Resource Sharing (CORS) is a security mechanism that’s implemented
by web browsers that determine which external domains can access your API
endpoints. Here’s how to configure it in a Next.js application:
// pages/api/data.js
export default async function handler(req, res) {
// Configure CORS headers
res.setHeader(
‘Access-Control-Allow-Origin’,
‘https://trusted-site.com’
)
res.setHeader(‘Access-Control-Allow-Methods’, ‘GET, POST, OPTIONS’)
res.setHeader(‘Access-Control-Allow-Headers’, ‘Content-Type’)
// Required if handling authentication
res.setHeader(‘Access-Control-Allow-Credentials’, ‘true’)
// Add security headers
res.setHeader(‘X-Content-Type-Options’, ‘nosniff’)
res.setHeader(‘X-Frame-Options’, ‘DENY’)
res.setHeader(
‘Content-Security-Policy’,
“default-src ‘self’; script-src ‘self’ ‘unsafe-inline’;” +
“style-src ‘self’ ‘unsafe-inline’; object-src ‘none’”
)
// Prevents referrer leaks
res.setHeader(‘Referrer-Policy’, ‘no-referrer’)
// Handle preflight (CORS) requests
if (req.method === ‘OPTIONS’) {
res
.writeHead(204, {
‘Access-Control-Allow-Origin’: ‘https://trusted-site.com’,
‘Access-Control-Allow-Methods’: ‘GET, POST, OPTIONS’,
‘Access-Control-Allow-Headers’: ‘Content-Type’,
‘Access-Control-Allow-Credentials’: ‘true’
})
.end()
return
}
// Validate request method
const allowedMethods = [‘GET’, ‘POST’]
if (!allowedMethods.includes(req.method)) {
res.status(405).json({
error: ‘Method Not Allowed’
})
return
}
try {
// Your API logic here
const data = await getData()
res.status(200).json(data)
} catch (error) {
console.error(‘API Error:’, error)
127
res.status(500).json({
error: ‘An error occurred while processing your request’
})
}
}
Install the ioredis package, then create the following `middleware.js` file in the
Next.js root folder.:
128
await redis.expire(key, rateLimitWindow);
}
return response;
}
Next Steps
If your app is deployed to Vercel, Vercel Edge middleware is a great alternative for rate
limiting.
For applications using Platformatic Watt and Composer you can implement rate-limiting
using the @fastify/rate-limit plugin.
mkdir new-app
cd new-app
npx wattpm@latest init
mv ../old-app web/app
129
7. Add a composer using npx create-platformatic and choosing the answers like
shown below.
Unset
cd web/composer
npm install @fastify/rate-limit ioredis
130
9. Create the web/composer/rate-limiter.js with the following contents:
return app.register(require(‘@fastify/rate-limit’, {
max: 100,
timeWindow: ‘1 minute’
})
}
{
“$schema”: “https://schemas.platformatic.dev/@platformatic/
composer/2.45.0.json”,
“composer”: {
“services”: [{
“id”: “app”,
“proxy”: {
“prefix”: “/app”
}
}],
“refreshTimeout”: 1000
},
“plugins”: {
“packages”: [
{
“path”: “./rate-limiter.js”
}
]
},
“watch”: true
}
Note: Infrastructure concerns like CDN setup, SSL certificates, and server
configuration should be handled separately in your deployment strategy.
131
Wrapping Up
Throughout this chapter, we explored how SSR enables search engines to efficiently
index content while improving initial page load speeds—resulting in a better user
experience, particularly for those on slower networks or less powerful devices.
It also offers Static Site Generation (SSG) and Incremental Static Regeneration (ISR),
allowing developers to fine-tune how pages are rendered and updated.
To put these concepts into practice, we built an event listing application using Next.
js 13+ with App Router. This project demonstrated dynamic routing, SSR-powered
pages, data fetching strategies, and modern React features like Suspense and
streaming. We also covered image optimization with next/image, prefetching for
improved navigation, and caching techniques to balance performance with scalability.
Of course, SSR comes with trade-offs. Rendering pages on the server increases CPU
and memory usage, making caching strategies, load balancing, and error handling
critical for scalability.
We explored different deployment options, including Vercel for ease of use, AWS
Amplify for cloud integration, and self-hosting for custom control, as well as Next.js’s
built-in caching mechanisms for optimizing performance.
132
5
Managing
Configurations
Why are configuations important,
how to provide them & implement
them in Node.js & best practices
1 Managing Configurations 136
Secrets
Sensitive information related to behavior settings and dependencies,
including API keys, authentication tokens, and encryption keys.
136
Why is Configuration Important?
Flexibility
Configuration allows applications to seamlessly operate across
different environments (development, testing, and production) by using
environment-specific settings.
Maintainability
Separating configuration from code improves readability, simplifies
updates, and streamlines deployments.
Security
Storing sensitive information like API keys and database credentials
directly in code poses a security risk. A well-designed configuration
system keeps such data external, minimizing the risk of accidental
exposure.
For Services
Configuration files and environment variables are commonly used.
Configuration files come in various formats, with .env, JSON, and YAML being among
the most popular.
137
5.2 The Twelve-Factor Methodology
Easy Onboarding
Declarative formats enable automated setup, making it easier for new
developers to get started.
Portability
The application maintains a seamless relationship with the underlying
operating system, ensuring predictable deployment across different
environments.
Cloud Readiness
Applications built with Twelve-Factor principles are designed for smooth
deployment in cloud environments.
Scalability
The methodology encourages stateless processes and externalized
configurations, ensuring efficient scalability.
138
What is an Environment in Software Development?
Different environments are used at various stages of the software development lifecycle
to ensure smooth transitions from writing code to running it in production.
Local
The developer’s personal machine, where individual code is written,
debugged, and tested in isolation. This environment is often used for
early-stage development and quick iterations before sharing code with
others.
Development
A shared environment where developers integrate their work into
a common codebase. It allows for collaboration on features, with
developers often using it to test new features before moving to testing
or staging. It might be configured to simulate certain production-like
conditions but is usually less restrictive.
Testing
A controlled space used for rigorous testing. Automated and manual
tests are run here to verify the functionality, stability, and performance
of the software. It may involve unit tests, integration tests, and other
validation mechanisms to catch bugs early and ensure quality.
139
Staging
A pre-production environment that closely mirrors production. It is
used for final testing and user acceptance testing (UAT) to ensure that
the application performs as expected under conditions similar to the
live environment. Staging serves as a safety net before deploying to
production.
Production
The live environment where end users interact with the application. This
is the most critical environment, and it must be highly reliable, secure,
and optimized for performance. Any issues here can directly impact
users, so it should always be carefully monitored and maintained.
140
Note also CI/CD systems often require specific configuration values or flags to set up
the context for testing and building the application.
This ensures that the application behaves correctly within the testing and deployment
contexts, with the appropriate resources and services accessible during each phase.
Now that we’ve covered the fundamental concepts of configuration management, let’s
explore how to provide runtime configuration settings in a Node.js application.
Node.js offers built-in mechanisms for handling runtime configurations through:
The process.env object contains the current environment variables as key-value pairs.
These variables can be used to configure application behavior dynamically without
modifying the code.
{
“USER”: “alice”,
“PATH”: “~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin”,
“PWD”: “/home/alice”,
“HOME”: “/home/alice”
}
141
Using Command-Line Arguments (process.argv)
In this case:
[
‘/usr/local/bin/node’, // Node.js executable path
‘/path/to/download.mjs’, // Path to the script
‘http://someurl.com/file’ // User-provided argument
]
Here’s how we can use process.env and process.argv to configure a simple ESM script
dynamically:
142
const url = process.argv[2]; // The URL is the third argument
if (!url) {
console.error(“Error: No URL provided.”);
process.exit(1);
}
#!/usr/bin/env node
import { parseArgs } from ‘node:util’
try {
const options = {
number: { type: ‘string’ },
help: { type: ‘boolean’, short: ‘h’ }
}
const { values } = parseArgs({ options });
if (values.help) {
console.log(`
Usage: fibo --number <number>
Options:
--number Number to calculate the Fibonacci sequence
`)
process.exit(0)
143
}
And run it as
./fibo --number=1
When an application requires multiple configuration values, passing them all via
environment variables or command-line arguments becomes impractical. A more
structured approach would be to use configuration files, allowing settings to be stored
in a single file that the application loads at startup.
The most common practice is to use a .env file, which can be loaded with the dotenv
https://www.npmjs.com/package/dotenv library. However, starting from Node.js v20,
.env files can be natively loaded using the --env option, eliminating the need for an
external library.
Regardless of the approach, the .env file is read at startup, and its contents are injected
into process.env, potentially overriding any pre-existing environment variables.
144
.env File Format
A .env file follows a simple key-value format, similar to shell environment files:
DB_HOST=localhost
DB_PORT=5432
DB_USER=myuser
DB_PASSWORD=supersecret
LOG_LEVEL=debug
Avoid using and committing .env files per environment (e.g., .env.
dev, .env.test). Configuration management should be decoupled
from the code, with environment variables provided externally.
Moreover, configuration must not include any application data that directly or indirectly
depends on the environment. Such data belongs to the application’s storage layer and
must be handled separately.
145
Other Configuration File Formats
Besides .env files, applications may use other formats for configuration, the most
popular are JSON or YAML.
While this is a valid design choice, mixing multiple sources adds complexity. The
recommended approach is to keep configuration management simple and consistent
by using a single, well-defined configuration format.
Hardcoding creates security risks, as sensitive information like secrets are tightly
coupled with the code and exposed to anyone with access. It also decreases codebase
maintainability, as settings are often repeated across the codebase, making them
difficult to change and prone to inconsistencies. This approach can be seen as a failure
to implement proper configuration management.
While it’s common for an application to have a global configuration, adopting a global
configuration scope loaded from every script can be considered an anti-pattern
because global variables are viewed as poor design practices.
Although using a single module to hold configuration settings might seem convenient,
it can hinder the creation of modular and composable code.
146
Note: The recommended design approach is to explicitly pass configuration values
as inputs to the modules, classes, or functions that require them. This decouples the
application’s overall configuration from individual module options, allowing greater
flexibility.
Any code entry point should have its own options, ensuring that each part of the
application operates independently without relying on a shared global state.
Additionally, this approach makes it easier to inject different configurations for testing
purposes, improving maintainability and reducing unintended dependencies.
One of the main issues is limited modularity and composability. When configuration is
stored in a global object or module, components become tightly coupled to it, making
it harder to design reusable, self-contained, and easily composable modules.
Hidden dependencies are another problem. When modules implicitly rely on a global
configuration object, it obscures dependencies, making the code harder to understand,
refactor, and maintain. Without explicitly passing configuration where needed, it
becomes unclear which parts of the application depend on specific settings.
147
In dynamic environments, there is also a risk of race conditions. If configuration values
are modified at runtime—for example, when settings are reloaded dynamically—
different parts of the application may read inconsistent data, leading to unpredictable
behavior and hard-to-debug issues.
Lastly, security vulnerabilities arise when sensitive information, such as API keys and
credentials, is stored in a global object.
Any part of the application can access it, increasing the risk of accidental leaks or
unauthorized access.
In lib/database.js we define the database handler, which directly imports the global
config. As we can see, this way creates tight coupling from the “connect” function and
the config
function connect() {
const { host, port, user, password, name } = config.database;
const pool = new Pool({
host,
port,
user,
password,
database: name
});
return pool;
}
148
Now, let’s examine the proper implementation to address this design issue. In lib/
database.js, rather than importing a global configuration file, the function accepts
configuration values as parameters, conventionally called options ensuring flexibility
while keeping the configuration structure unchanged.
function start() {
const db = database.connect(config.database);
...
}
149
5.6 Anti-pattern: process.env.NODE_ENV Static Pollution
This pattern uses the NODE_ENV variable to identify the current working environment,
typically set to development, test or production. It directly depends on this value
to determine the application’s behavior, such as optimizing performance, enabling or
disabling debugging tools, or activating environment-specific features. The biggest
problem of this practice is that development in this context signifies the “local”
environment from an infrastructure point of view. As one can expect, when two teams
disagree on terminology, significant problems arises.
This convention was inspired by earlier software engineering practices, such as the
use of environment variables in Unix systems and frameworks like Ruby on Rails,
which employed similar variables (e.g., RAILS_ENV) to manage runtime environments.
Over time, many libraries and frameworks—such as Nest.js and tRPC —have adopted
this convention, prompting developers to implement conditional logic based on the
environment.
However, when such code snippets are scattered across the codebase, maintaining
the application becomes increasingly challenging.
150
if (process.env.NODE_ENV === “production”) {
enableOptimizations();
} else {
enableDebuggingTools();
}
---
if (process.env.NODE_ENV === “production”) {
enableRateLimiting();
} else {
disableRateLimiting();
}
---
if (process.env.NODE_ENV === “test”) {
useMockData();
} else {
useRealData();
}
One major issue is the scattering of configuration logic across the codebase. Instead of
centralizing configuration settings, this approach embeds environment-specific logic
within multiple modules, making the application more difficult to maintain, refactor, and
debug. Furthermore, since different environments may have varying configurations,
inconsistencies can arise, increasing the likelihood of errors.
Another issue is the tight coupling of the code to the execution environment. Frequent
checks against process.env.NODE_ENV violates the Separation of Concerns1
principle– which outlines that applications should not be written as one solid block–
by intertwining business logic with environment-specific settings. A more effective
approach is to inject the appropriate configuration at startup rather than querying
environment variables dynamically during execution.
This approach also complicates testing and debugging. When application behavior is
dictated by NODE_ENV, developers must manually set environment variables to simulate
different conditions. Additionally, misconfigurations can persist across processes,
leading to unintended side effects in unrelated tests. Debugging failures can also
become non-deterministic due to inconsistencies between different environments.
151
common in many projects. Hardcoding logic based on a single environment variable
does not scale as configuration complexity grows.
Finally, there are security risks associated with relying on NODE_ENV for security-
sensitive behavior. It is a common but incorrect assumption that setting NODE_
ENV=”production” automatically enforces a secure configuration. Instead, applications
should explicitly load secure defaults from an external configuration source rather than
depending on a single environment variable.
To effectively address this issue and provide the flexibility to enable or disable features,
feature flags offer an ideal solution. Instead of relying on a single environment variable,
we define fine-grained feature flags, each tied to its own environment variable. This
allows explicit control over application behavior based on these values.
For example:
if (config.enableDebuggingTools) {
enableDebuggingTools();
}
if (config.enableRateLimit) {
enableRateLimiting();
} else {
disableRateLimiting();
}
if (config.useMockData) {
useMockData();
} else {
useRealData();
}
152
We can define the respective environment variables for different environments such
as development, test, or production. This approach ensures flexibility and allows clear
control over features without requiring code changes, even when we need to enable
rate limiting in development to debug it, for instance. It also enables easy introduction
of new environments by simply adjusting the configuration.
Here’s how you can set this up in your configuration file, with production serving as the
default configuration:
lib/config.js
USE_MOCK_DATA=true
ENABLE_DEBUGGING_TOOLS=true
ENABLE_RATE_LIMIT=false
USE_MOCK_DATA=false
ENABLE_DEBUGGING_TOOLS=false
ENABLE_RATE_LIMIT=true
This ensures that the application behaves differently across environments without
requiring changes to the codebase.
153
5.7 Anti-pattern: Hierarchical Configurations
154
Moreover, the config module operates globally within the application. This makes
it difficult to manage configurations in a modular or isolated manner, increasing the
complexity and making the configuration more prone to errors as the application grows.
Example:
A common setup for configuration involves the following steps:
1. The config library initially loads the configuration from ./config/default.json.
2. It then loads the environment-specific file ./config/{NODE_ENV}.json, overriding
values from the default configuration.
3 . If the NODE_ENV is not set, it defaults to loading the development configuration.
- config /
- default.json
- development.json
- production.json
This file contains the common configuration shared across all environments:
{
“server”: {
“port”: 3000,
“host”: “localhost”
},
“database”: {
“connectionString”: “localhost”,
“port”: 5432,
“name”: “myapp”,
“user”: “dbuser”,
“password”: “default_password”
},
“logging”: {
“level”: “info”
}
}
155
config/development.json - Development Configuration
{
“server”: {
“port”: 3001
},
“database”: {
“password”: “dev_password_123”
},
“logging”: {
“level”: “debug”
},
“features”: {
“enableDebug”: true,
“mockServices”: true
}
}
Not the primary security issue: passwords are stored in plain text, making them
accessible to anyone with access to the code.
This poses a serious security risk and also constitutes a legal violation, such as non-
compliance with GDPR.
156
{
“server”: {
“port”: 80,
“host”: “0.0.0.0”
},
“database”: {
“host”: “prod-db.example.com”,
“password”: “prod_super_secret_password_xyz” // plain password!
},
“logging”: {
“level”: “warn”
},
“features”: {
“enableDebug”: false,
“mockServices”: false
},
“cache”: {
“redis”: {
“host”: “redis.prod.example.com”,
“password”: “redis_prod_password” // plain password!
}
}
}
157
‘use strict’
const config = require(‘config’)
const fastify = require(‘fastify’)({ logger: true })
// Start the server
const start = async () => {
// Log the current environment
console.log(`Running in ${process.env.NODE_ENV || ‘default’}
environment`)
// The config library automatically loads configuration files based
on NODE_ENV:
// 1. It first loads ./config/default.json
// 2. Then it loads ./config/{NODE_ENV}.json and overrides values
from default
// 3. If NODE_ENV is not set, it defaults to ‘development’
console.dir({
environment: process.env.NODE_ENV || ‘default’,
config: {
server: {
host: config.get(‘server.host’),
port: config.get(‘server.port’)
},
database: {
port: config.get(‘database.port’),
name: config.get(‘database.name’),
user: config.get(‘database.user’),
password: ‘***’
},
logging: {
level: config.get(‘logging.level’)
},
// Features may only exist in certain environment configs
features: config.has(‘features’) ? {
enableDebug: config.get(‘features.enableDebug’),
mockServices: config.get(‘features.mockServices’)
} : ‘Not configured for this environment’,
// Cache may only exist in production config
cache: config.has(‘cache’) ? {
redis: config.get(‘cache.redis’)
} : ‘Not configured for this environment’
}
}, { depth: null })
try {
const port = config.get(‘server.port’)
const host = config.get(‘server.host’)
158
This pattern hinders team efficiency and compromises your application’s security. It
must be avoided, as it slows your team down and increases security risk.
Now that we’ve explored common anti-patterns, let’s focus on best practices for
managing configurations in a Node.js application.
Let’s explore the recommended approach, and then see how to apply all of these best
practices in a practical example.
159
Single Source of Truth
The most effective way to manage configuration is to centralize it in a single file, thereby
avoiding scattered configuration logic throughout the codebase and ensuring that all
configuration values are gathered in one location.
This configuration file should handle all operations related to configuration, including
the collection of environment variables, loading from files, defining optional settings,
setting default values, and performing validation. By consolidating all configuration-
related tasks into one file, developers can be confident that there are no unmanaged
or inconsistent configuration elements within the codebase.
A common approach is to create a config.js file in the application code, where all
configuration values are defined and exported.
This ensures:
Consistency
All settings are in one place, making it easy to manage and update.
Maintainability
Developers don’t have to search across multiple files to modify
configurations.
Separation of Concerns
The application logic remains independent of environment-specific
settings.
This approach creates a clear and structured configuration system, improving DX and
reducing the likelihood of misconfigurations.
Default Values
Setting default values for configuration is a best practice, especially for Application
Behavior Settings. These include parameters such as connection timeouts, retry limits,
and logging levels, which can be predefined in the configuration file and overridden
when necessary.
160
Default values should always be aligned with the production environment, as it is the
primary deployment target. Development and staging environments should explicitly
override these defaults as needed.
Providing defaults for these critical settings could lead to unintended behavior, such as
accidentally using production settings in development environments.
Instead, the application should fail to start if these vital settings are missing, throwing
an error to ensure the issue is addressed before deployment.
Validation
After inputting configuration values, we must ensure they are correct at runtime. A
good way to do so is by validating environment variables.
When the validation fails, it will throw an error with details of the incorrect values
Various libraries can help handle this, including env-schema, zod, and TypeBox.
Note: handling non-string values is a common issue when working with environment
variables. Since all environment variables are inherently treated as strings, values like
booleans need to be explicitly converted.
For example, “true” or “false” as strings must be parsed into their respective boolean
types (true/false). Proper conversion ensures that the application behaves as expected
when using non-string values like numbers, booleans, or arrays.
161
Using env-schema
Example: lib/config.js
const schema = {
type: ‘object’,
properties: {
PORT: {
type: ‘number’,
minimum: 1, maximum: 65535,
default: 3000,
},
HOST: {
type: ‘string’,
default: ‘127.0.0.1’,
},
LOG_LEVEL: {
type: ‘string’,
enum: [‘fatal’, ‘error’, ‘warn’, ‘info’, ‘debug’, ‘trace’],
default: ‘info’,
},
API_KEY: {
type: ‘string’,
minLength: 10,
},
},
required: [‘API_KEY’]
};
162
Combining env-schema with TypeBox for Type Safety
Since env-schema does not provide TypeScript types, it can be combined with TypeBox
for type safety. This ensures that TypeScript enforces the structure at compile time.
Example: lib/config.ts
163
Using zod
A more type-friendly alternative is zod, which provides a fluent API for defining and
validating schemas.
dotenv.config()
Runtime Validation
While static validation ensures that environment variables are correctly formatted and
present, it does not guarantee that the values are valid in a real-world context. Runtime
validation overcomes this limitation by ensuring that environment variables, although
syntactically correct, also hold practical validity during application execution.
For instance, a database connection string or an external URL may pass static syntax
checks but could still fail at runtime if the database is unreachable or if the URL is
unavailable.
164
checks directly in the configuration layer can introduce unnecessary complexity due to
external dependencies.
By combining static validation with runtime checks, we ensure that configuration values
are not only correctly formatted but also practically valid, reducing the risk of issues
arising during the application’s execution.
Segregation
This approach ensures that different parts of the application have access only to
the configuration values they need, thus enhancing security. This also minimizes the
amount of data passed to each module, ensuring that only the smallest, most relevant
subset of configuration is provided.
Sometimes, the application design requires accessing multiple parts of the configuration.
In such cases, we can:
We should avoid passing the entire configuration to modules, even if this approach
introduces some logic and overhead. This ensures that the configuration is used
securely and efficiently, without unnecessary complexity.
Readonly
165
Once the configuration is frozen, any attempt to modify its properties will result in a
runtime error, effectively safeguarding the configuration from accidental changes. This
practice also eliminates the need to create deep copies of the configuration, which can
be computationally expensive and inefficient during execution.
Handle secrets
Handling secrets requires careful attention to ensure they remain secure and are not
inadvertently exposed. The common practice of passing secrets via environment
variables at runtime is inherently risky since environment variables can be accessed by
anyone with the ability to inspect the running process.
If an attacker gains access to a pod or server, they can retrieve these values using
commands like ps or by reading from /proc/<pid>/environ. Additionally, environment
variables are often visible in container configurations and pod specifications. For
example, running kubectl describe pod can reveal environment variables in plaintext.
To mitigate these risks, tools like AWS Secrets Manager, HashiCorp Vault, and
Kubernetes Secrets allow secrets to be securely stored and injected into applications
at runtime without exposing them through environment variables.
One of the primary challenges in secret management is the “secret zero” problem, which
refers to the dilemma of how to securely authenticate and retrieve secrets initially,
without relying on hardcoded credentials. The core issue lies in the fact that to retrieve
or access other secrets, you first need a secret to get started. If that initial secret (the
“secret zero”) is stored insecurely or hardcoded into your application, it defeats the
purpose of securing the rest of your secrets.
166
Vault tackles this challenge by offering dynamic authentication mechanisms. Instead
of relying on static, hardcoded credentials (which are often exposed or compromised),
Vault provides a system where credentials are dynamically generated, typically based
on the identity of the requester. This means that each time the application or service
needs to authenticate, it can request temporary, one-time-use credentials or tokens
from Vault. These credentials are time-limited and do not require long-term storage,
thus removing the risk of exposing secrets in your codebase.
2
https://www.geeksforgeeks.org/shamirs-secret-sharing-algorithm-cryptography/
167
Once secret access is managed at the infrastructure level, it becomes the developer’s
responsibility to ensure that secrets remain secure within the application code.
Developers must take care to prevent secrets from being inadvertently exposed
through logs. If secrets must be logged for debugging purposes, they should always
be redacted or masked before being output.
To maintain flexibility during development, secrets can still be retrieved from environment
variables when necessary, with access controlled through feature flags. In the final
example of this chapter, we will demonstrate how to properly configure an application
to handle secrets securely and efficiently.
.e .e .e
nv nv nv
on of
f on
Feature flags, also known as feature toggles, provide an elegant and effective way to
manage different behaviors of an application at runtime.
One of the main advantages of feature flags is that they eliminate the need to rely on
environment variables like NODE_ENV to control application behavior. By decoupling
configuration from the code logic, feature flags centralize feature management. This
not only cleans up the codebase but also makes it more maintainable.
Additionally, feature flags provide explicit control over which features are enabled
or disabled, reducing the risk of unintended behaviors or hidden changes in the
application. This approach ensures that modifications to features are intentional, and
makes it easier to manage complex configurations, as each feature can be toggled
independently, without requiring code changes.
Moreover, feature flags provide a clear mechanism for rolling out new features
incrementally, testing new functionalities in production, or quickly rolling back changes
if something goes wrong, all without deploying new versions of the code.
168
Implementing Configuration Best Practices
Now that we’ve explored the recommended best practices for managing configuration,
let’s discuss how to implement them in a practical setting. The following steps will
guide you through establishing a well-structured, scalable, and secure configuration
management system in your application.
.
├── lib
│ ├── app.js
│ ├── config.js
│ ├── logger.js
├── plugins
│ ├── db.js
├── main.js
Configuration (lib/config.js)
• Schema Validation: Uses zod for runtime type checking and validation
• Environment Variables: Configuration is sourced from environment variables
• Type Coercion: Automatic type conversion for numeric values
• Default Values: Sensible defaults for optional configurations
• Secrets Management: Optional HashiCorp Vault support for secrets management
• Feature Flags: Structured approach to feature management
• Immutability: Configuration object is frozen using Object.freeze
169
Key implementation:
170
connectionString: vaultSecrets.DATABASE_CONNECTION_STRING ??
process.env.DATABASE_CONNECTION_STRING,
},
features: {
exposeConfig: process.env.FEATURE_EXPOSE_CONFIG,
allowedOrigins: process.env.ALLOWED_ORIGINS,
useVault: process.env.FEATURE_USE_VAULT
}
}
Application (lib/app.js)
return app;
}
171
Database Plugin (plugins/db.js)
// Validate connection
try {
await pg`SELECT 1`;
app.log.info(‘Database connection successful’);
} catch (err) {
app.log.fatal({ err }, ‘Failed to connect to the database’);
throw new Error(‘DATABASE_CONNECTION_ERROR’, { cause: err });
}
app.decorate(‘pg’, pg);
app.addHook(‘onClose’, async () => {
await pg.end();
});
}
Logger (lib/logger.js)
function censor(value) {
if (typeof value === ‘string’) {
return value ? `[redacted,len:${value.length}] ${value.
substring(0, 3)}...` : value;
172
}
return ‘[redacted]’;
}
return pino({
level: options.level,
redact: {
paths: options.redactions,
censor: censor
},
transport
});
}
let logger;
try {
logger = createLogger(config.log);
} catch (err) {
console.error(JSON.stringify({ err, message: ‘Failed creating
logger’ }, null, 2));
process.exit(1);
}
try {
const app = await createApp({ config, logger });
logger.info({ config }, ‘App starting’);
await app.listen({ port: config.app.port, host: config.app.host
173
});
logger.info({ port: config.app.port, host: config.app.host },
‘App started’);
} catch (err) {
logger.fatal({ err }, ‘App crashed on start’);
process.exit(1);
}
}
• Validation at Boundaries
Configuration is validated immediately when loaded
• Type Safety
Strong typing through Zod schema
• Environment-Based
Configuration through environment variables
• Immutable Config
Prevents runtime configuration modifications
• Modular Design
Each component receives only its required configuration
• Secure Defaults
Sensible default values for optional settings
• Feature Flags
Structured approach to feature management
174
• Single Source of Truth
Configuration is loaded once at startup
• Secrets Management
Optional HashiCorp Vault integration for secure secrets
Environment Variables
# Application
APP_PORT=3000
APP_HOST=localhost
# Logging
LOG_LEVEL=info
LOG_PRETTY=true
# Database
DATABASE_CONNECTION_STRING=postgres://user:pass@localhost:5432/db
# Features
FEATURE_EXPOSE_CONFIG=false
ALLOWED_ORIGINS=localhost:3000
FEATURE_USE_VAULT=false
175
Vault Integration
1. Local Development:
• Set FEATURE_USE_VAULT=false to use environment variables
• Configure values directly in .env file
2. Kubernetes Deployment:
• Set FEATURE_USE_VAULT=true to enable vault integration
• Configure vault connection in deployment manifest:
env:
- name: FEATURE_USE_VAULT
value: “true”
- name: VAULT_ADDR
value: “http://vault:8200”
- name: VAULT_ROLE
value: “myapp”
- name: VAULT_SECRET_PATH
value: “secret/data/myapp”
{
“DATABASE_CONNECTION_STRING”: “postgres://user:pass@db:5432/app”,
}
176
4. F
allback Behavior:
• If a secret is not found in Vault, falls back to environment variables
• All configuration validation remains active regardless of source
177
In case of an error, the application will print the error message and exit with a non-
zero exit code, for example with an empty DATABASE_CONNECTION_STRING and/or
invalid APP_PORT:
{
“err”: {
“issues”: [
{
“code”: “too_small”,
“minimum”: 1024,
“type”: “number”,
“inclusive”: true,
“exact”: false,
“message”: “Number must be greater than or equal to 1024”,
“path”: [
“app”,
“port”
]
},
{
“code”: “too_small”,
“minimum”: 1,
“type”: “string”,
“inclusive”: true,
“exact”: false,
“message”: “String must contain at least 1 character(s)”,
“path”: [
“db”,
“connectionString”
]
}
],
“name”: “ZodError”
},
“message”: “Failed loading config”
}
178
Wrapping Up
By following these best practices, you’ll be able to reduce security risks, enhance
the developer experience, and ensure that configuration changes do not introduce
unintended issues, ultimately making your application more reliable and easier to
manage as it evolves.
179
6
Structuring Large
Applications
Core benefits of modularity, common
architectural pitfalls & best practices for
constructing robust and maintainable systems
1 Module Management 183
Node.js is fundamentally built around the concept of modularity. Each JavaScript file is
treated as an independent module: a self-contained, reusable unit of code. A module
typically encapsulates logic related to a specific context, which may include functions,
classes, constants, and variables. By isolating related logic into modules, developers
can decompose complex applications into smaller, focused components that are easier
to reason about and maintain.
Each module defines and exposes a clear interface: usually through module.exports
or export statements: allowing other parts of the application to interact with it
in a controlled manner. This modular structure enhances code reusability, enforces
separation of concerns, and contributes to better organization and long-term
maintainability across the codebase.
We will explore the core benefits of modularity, identify common architectural pitfalls,
and present best practices for constructing robust and maintainable systems. Topics
will include: dependency injection as a means of decoupling components; the principle
of separation of concerns to improve readability and testability; and alternative patterns
to the traditional MVC architecture.
The ultimate goal is to equip you with the tools and mindset required to design Node.
js applications that are clean, coherent, and prepared to evolve alongside your product
and business.
182
6.1 What is a Module?
Encapsulation
They hide implementation details and expose only what is necessary.
Reusability
Code can be reused across different parts of an application or even
across projects.
Maintainability
Smaller, focused files are easier to test, debug, and extend.
Composability
Applications are built by composing many small, independent modules.
Node.js provides support for two primary module systems: CommonJS (cjs), which
uses require and module.exports, and ECMAScript Modules (esm), which use import
and export.
The following example adopts the esm syntax, which is the modern standard and offers
backward compatibility for consuming cjs modules when needed.
183
auth.js
try {
const decoded = jwt.verify(token, secret);
cache.set(token, decoded);
return decoded;
} catch {
return null;
}
}
As observed, only the two functions are exposed as part of the module’s public interface,
while all other elements remain encapsulated within the module’s internal scope.
184
The Singleton Pattern: Convenience or Hidden Coupling?
The singleton pattern is a common technique used to ensure that a module exposes
only a single, shared instance throughout the entire application. In Node.js, this is often
achieved naturally due to the way modules are cached: once a module is loaded, its
exports are cached, and subsequent import or require calls return the same instance.
This behavior can be convenient for utilities like loggers, configuration loaders,
or database connections. However, overusing or misusing singletons can lead to
unintended consequences, especially in large applications.
logger.js
let logger;
At first glance, this pattern seems harmless. But it becomes problematic when the
application grows and requires:
A singleton logger, instantiated once and reused across the entire application, makes
these requirements challenging to fulfill. It behaves as an implicit global, sharing its
configuration and state universally, which limits flexibility and introduces hidden
coupling.
185
Shared Singletons and Module Resolution Pitfalls
Due to how Node.js resolves modules, if multiple parts of the application (or multiple
packages) depend on the same module and resolve the same version, Node.js will load
it once, from the nearest node_modules directory, and cache that instance globally
across the runtime.
This means that even if different parts of your application expect isolated instances,
they will in fact be sharing the same singleton, unintentionally.
This can lead to unexpected behaviors, especially when the singleton holds internal
state or context.
myapp/
├── node_modules/
│ ├── logger/
│ ├── service-a/
│ ├── service-b/
├── app.js
Let’s say both service-a and service-b depend on a shared logger module, and
both resolve to the same version of logger, as implemented above.
You might expect the following behavior:
But due to Node.js module resolution, only the first resolved module will be cached, and
all subsequent imports will receive the same instance, even across seemingly isolated
packages.
186
This means if service-a is loaded first and initializes the logger like this:
And then service-b later tries to use the logger with different options, for example a
different log level:
Then in myapp/app.js
serviceA();
serviceB();
The expected behavior is to see only the message “Service A is running”, since its
logger is configured at the debug level.
However, the output also includes “Service B is running”, even though Service B is
intended to log only messages at the warn level or higher.
187
Here’s what happens step by step:
Step 1
1 Service A is loaded.
Step 2
2 It calls createLogger with a debug log level.
Step 3
3 The logger is created and returned.
Step 4
The logger instance is cached by Node.js’ module system.
Step 5
5
Service B is loaded afterward.
Step 6
6
It attempts to create a new logger with a warn level using createLogger.
Step 7
7 Instead of getting a new logger, it receives the already cached logger
instance from Service A.
Step 8
8 Since the logger is still configured at debug level, Service B’s info
messages are printed — even though they should have been suppressed.
This can result in excessive logging, leaked configuration, and hard-to-debug side
effects, especially in large monorepos or plugin-based systems.
This approach allows each consumer to create and configure its own instance based
on its specific context.
188
6.2 Dependency Injection
return {
getToken,
stop
}
}
189
In the example.js file, we have
async stop() {
console.log(“Something Stopped”);
},
};
}
console.log(await something());
console.log(await something());
console.log(await something());
await stop();
await stop1();
Imagine this in a larger application– we’d have to search through our entire codebase to
implement our changes. This would go against a common programming rule, “Program
to an interface, not an implementation.”
Moreover, dependencies make it easier to attach decorators to code. This forms the
basis on which one of Fastify’s major features is built: plugins.
Dependency injection also works well with classes. So if we were using classes, we
would have this in the example.js.
190
async function fancyCall() {
console.log(“Fancycall”)
return 42;
}
class Example {
constructor() {
this.accessToken = null;
}
async getToken() {
if (!this.accessToken) this.accessToken = await fancyCall();
return this.accessToken
}
async stop() {
this.accessToken = null;
console.log(“Access Token Stopped”)
}
}
class DoSomething {
constructor({ example }) {
this.example = example;
}
async something() {
const token = await this.example.getToken();
return token * 2;
}
async stop() {
console.log(“Something Stopped”);
}
}
191
6.3 Splitting Your Application Packages Into Modules
Most applications are built using the MVC (Model-View-Controller) model, whereby
each section of your code has a specific and unique purpose, allowing you to split out
the front and backend code into separate components.
As the name would suggest, this model is broken down into three parts: Model code,
View code and Control code.
The Model is a central component, working with the database to hold raw-data, logic,
and rules of an application.
The View is the user interface section of the app. In backend applications, it contains
some HTML, CSS, XML, JS or other languages for the user interfaces. These user
interfaces can be used to send basic forms to receive data to complete a process or
update your users on a process.
The Controller handles all of the logic of the application. It handles all the processes
and methods of the application, sends the response, and handles errors.
With MVC, the three core layers—Model, View, and Controller—define where new
functionality can be introduced. While this separation of concerns provides structure,
it can become a bottleneck in large-scale applications.
A major challenge with MVC is the inevitable growth in the number of files, classes,
and dependencies, which can make code harder to navigate, debug, and refactor.
Over time, the architecture can become rigid, forcing developers to modify multiple
components when implementing changes. This increases the risk of regressions and
makes feature development slower.
192
Another common issue is that MVC often leads to tight coupling between layers.
Business logic frequently seeps into Controllers or even Views, leading to implicit
dependencies that erode modularity. As a result, making changes in one part of the
system can have unintended effects elsewhere, reducing maintainability.
While MVC works well for small to mid-sized applications, it struggles to scale cleanly.
Modules can help you scale your application’s complexities, particularly with the One-
Module-One-Feature approach. Under this approach, you would divide your applications
into domain logic which spreads across your entire application.
193
The project is organized around two main types of modules:
1. Shared Plugins
These represent the core business entities of the ecommerce platform. Each feature
module owns its own routing logic, domain-specific models, controllers, and service
logic. They encapsulate everything related to a single domain concern:
Each route module typically exports a router as a fastify plugin that will be registered in
the main application, and receives injected dependencies (such as logger, db, or auth)
for maximum flexibility and testability.
The lib/ directory contains shared stateless utilities that are generic and horizontal
to the application, such as configuration loaders, custom error classes, or validation
helpers.
194
Structure
/ecommerce
├── src/
├── lib/
│ ├── config.js
│ ├── plugins/
│ ├── auth/
│ ├── db/
│ ├── ...
│ ├── payment/
├── routes/
│ ├── users/
│ ├── products/
│ ├── ...
│ ├── orders/
├── app.js
├── server.js
We’ll walk through the code involved in setting up the application and processing an
order with payment.
This example will give us a practical look at how the key components of the system
come together in a real scenario.
195
src/app.js
The app.js file serves as the composition root of the application — the place where all
parts of the system come together.
Its primary responsibility is to instantiate the Fastify server, register shared plugins
(such as logging, configuration, authentication, and database access), and mount
feature modules that expose the business logic.
// Shared plugins
import db from ‘./plugins/db/index.js’;
import auth from ‘./plugins/auth/index.js’;
import payment from ‘./plugins/payment/index.js’;
return fastify;
}
src/main.js
The main.js file is the entry point of the application. It bootstraps the Fastify server
by calling the buildApp function from app.js, starts listening on the configured port,
and handles startup errors gracefully.
This file is minimal by design, keeping all initialization logic centralized in app.js for
better separation of concerns.
196
import { buildApp } from ‘./app.js’;
import { config } from ‘./lib/config.js’;
try {
await app.listen({ port: config.app.port, host: config.app.host });
app.log.info(‘Application started’);
} catch (err) {
app.log.fatal({ err }, ‘Unable to start the application’);
process.exit(1);
}
};
start();
src/routes/orders/index.js
It charges the customer using the payment plugin and updates the order status to
completed if successful. This implementation demonstrates clean integration between
feature logic and shared infrastructure.
fastify.post(‘/complete’, {
preHandler: fastify.auth, // assumes fastify.auth sets request.user
schema: {
body: {
type: ‘object’,
required: [‘orderId’, ‘paymentMethod’],
properties: {
orderId: { type: ‘string’ },
paymentMethod: { type: ‘string’ },
},
},
},
handler: async (request, reply) => {
197
const { orderId, paymentMethod } = request.body;
const userId = request.user.id;
try {
const order = await db.getOrderById(orderId, trx);
if (!order || order.userId !== userId) {
await trx.rollback();
return reply.code(404).send({ error: ‘Order not found’ });
}
if (!result.success) {
await trx.rollback();
return reply.code(402).send({ error: ‘Payment failed’ });
}
} catch (err) {
await trx.rollback();
logger.error({ err, orderId, userId }, ‘Order completion
failed’);
return reply.code(500).send({ error: ‘Internal server error’ });
}
},
});
}
This architectural approach offers several key advantages that make it well-suited for
building robust and maintainable systems. By establishing clear boundaries between
infrastructure and business logic, it ensures that each concern remains isolated, reducing
complexity and making the codebase easier to reason about. Modules are designed
with high cohesion and minimal coupling, which promotes internal consistency while
allowing different parts of the system to evolve independently.
198
One of the major strengths of this structure is its scalability. Although it begins as a
monolith, the architecture naturally supports a transition to a modular monolith or even
a microservices-based system as the application grows. This gradual evolution helps
teams avoid premature complexity while keeping future options open.
Testability is another important benefit. Since modules can be developed and executed
in isolation, with external dependencies mocked or stubbed, testing becomes more
straightforward and reliable.
This also contributes to a smoother onboarding experience: new developers can focus
on understanding and contributing to a single module at a time, without needing to
comprehend the entire codebase from day one.
Overall, this architecture lays down a clean and scalable foundation for complex
applications, without the initial overhead of distributed systems. It is particularly effective
for startups or teams that prioritize development speed, clarity, and simplicity, while
still preserving the flexibility to refactor or extract services as the product evolves.
199
6.4 From Monolith to Microservices: Evolving with Growth
As the business grows, however, the application often needs to accommodate a broader
set of features, serve a higher volume of users, and meet increasingly demanding non-
functional requirements such as scalability, fault isolation, team autonomy, and faster
deployment cycles.
At this stage, a cleanly designed monolith becomes an advantage rather than a liability.
Thanks to its modular structure and clear interfaces between features and services, it
becomes much easier to extract specific modules and evolve them into independent
microservices.
200
After building a well-structured monolithic application following the one-module-one-
feature approach—with distinct modules for users, orders, and products, and shared
fastify plugins such as authentication, database access, and payment processing—we
are now ready to explore how to evolve this design into a microservices architecture.
In this new setup, each feature becomes its own standalone service. The users,
orders, and products modules are extracted into separate applications, each running
independently and managing its own database. This separation enables better
scalability, isolation, and deployment flexibility.
201
/ecommerce-monorepo
├── services/
├── users/
│ ├── src/
│ ├── lib/
│ ├── config.js
│ ├── plugins/
│ ├── auth.js
│ ├── routes/
│ ├── auth.js
│ ├── profile.js
│ ├── app.js
│ ├── main.js
├── orders/
│ ├── src/
│ ├── lib/
│ ├── config.js
│ ├── plugins/
│ │ ├── auth.js
│ │ ├── db.js
│ │ ├── payment.js
│ ├── routes/
│ │ ├── orders.js
│ ├── app.js
│ ├── main.js
├── products/
├── src/
├── lib/
│ ├── config.js
├── plugins/
│ ├── auth.js
├── routes/
│ ├── products.js
│ app.js
│ main.js
In this microservice architecture, each application runs its own web server tailored to
its specific business requirements. Initially, since they originate from a monolithic split,
these services tend to share a similar structure. However, over time they naturally
diverge as they evolve to meet distinct business needs and handle varying workloads.
Because of this divergence, sharing code across services—even for common concerns
like logging, telemetry, or configuration—is often not practical. Reuse may introduce
tight coupling or unnecessary complexity, hindering independent evolution and
scalability.
202
6.5 Solving Microservice Challenges
Finally, while breaking down a Node.js application into independent modules or services
can improve modularity and performance, it also introduces deployment complexity.
Each service must be deployed, monitored, and maintained independently, adding
operational overhead.
At this stage, we begin to separate concerns by organizing the system into distinct
microservices, each responsible for a specific domain and encapsulating its own
business logic. This distributed architecture establishes clear boundaries between
services, allowing teams to work independently and enabling the system to scale more
predictably.
Even with a minimal setup, these modular services can handle thousands of users and
requests per second—providing scalability and resilience well-suited to the needs of a
growing startup.
However, it may still be premature to fully split the codebase into independently
deployed services. Instead, these services can continue to run within the same
monorepo or process space. This approach avoids the early overhead of inter-service
networking, separate deployments, and service discovery—while still reaping the
benefits of modularity, clear domain separation, and parallel development.
203
This hybrid model captures many of the advantages of microservices—such as
isolation, maintainability, and scalability—without the immediate cost of full operational
complexity. The first major hurdle in a true microservices setup is deployment: managing
multiple services, containers, versions, and orchestrators can quickly become a heavy
operational burden.
At this point, one thing becomes clear: microservices may not be the right fit for
most development teams, especially in the early stages of a product’s lifecycle. The
architectural complexity, operational overhead, and coordination demands can outweigh
the benefits—particularly when the team is small or the domain is still evolving.
However, multithreading introduces its own set of complexities. Since worker threads
share memory, careful coordination is required to avoid contention and ensure proper
resource isolation. A failure in one thread can potentially affect others if not properly
sandboxed. Additionally, developers must manage the lifecycle of threads—spawning,
monitoring, and restarting them as needed—adding orchestration overhead to the
application logic.
204
6.7 Introducing Platformatic Watt
It enables you to run multiple Node.js services that are centrally managed and
coordinated, streamlining the orchestration of complex applications. Services can be
executed using worker threads, which offer faster startup times and lower overhead,
or as child processes, which are better suited for services with complex startup
sequences or that require greater isolation.
Watt allows you to run multiple Node.js applications under one roof, with centralized
control and configuration.
It comes production-ready out of the box, equipped with a rich set of features that
every service typically needs: inter-service networking, support for .env file loading,
comprehensive logging, and distributed tracing.
All of these features can be easily configured using simple JSON files, eliminating
boilerplate code and improving long-term maintainability of your applications.
Automatic Multithreading
Watt intelligently parallelizes your services across available CPU
cores with a single command. This built-in multithreading improves
performance and resource efficiency—no manual thread orchestration
required.
205
Unified Logging with Pino
Watt leverages Pino for high-performance, structured logging. It delivers
consistent and centralized logs across all services, making it easier to
monitor performance, troubleshoot issues, and maintain visibility across
your Node.js architecture.
Additionally, Watt includes built-in health check routes, making it ready for seamless
integration with Kubernetes (k8s) and other orchestration systems right out of the box.
206
Instead of manually managing worker threads, Watt allows developers to run multiple
services within a single Node.js process, with each service executing in its worker
thread.
This design helps optimize CPU usage while keeping services modular and isolated.
Watt reduces infrastructure overhead while maintaining performance by eliminating
the need to run each microservice as a separate deployment unit (or container).
For example, consider an application with an API gateway, authentication, and data
processing services. In a traditional setup, each of these would run as a separate
deployment, leading to organizational inefficiencies.
With Watt, these services can run concurrently in separate worker threads within the
same process, preserving modularity without unnecessary overhead.
Fault isolation ensures that if one service encounters an issue, it doesn’t impact
the others. By fully using available CPU cores and automating much of the thread
management, Watt provides a structured and scalable way to integrate multithreading
into a Node.js application without adding unnecessary complexity.
Platformatic Watt provides a way to harness the benefits of worker threads while
keeping the architecture simple and scalable.
Beyond performance, this approach also improves security: since data is exchanged
entirely within the process memory, there is no network surface to secure, reducing the
risk of interception or external attacks.
207
Modern Tools for Implementing Networkless HTTP
Let’s dive a little deeper into how networkless HTTP can be implemented in Node.js
applications using modern tools—specifically, Undici and Fastify.
Undici
The native HTTP stack in Node.js has known performance limitations that are difficult to
resolve without breaking backward compatibility. This was the motivation behind Undici,
a fast, modern HTTP library for Node.js. It powers the built-in fetch implementation
in Node.js core and has become the go-to solution for high-performance HTTP
communication.
Originally, Undici supported only HTTP/1.1, but it has since evolved to include support
for HTTP/2, making it compatible with the latest versions of the HTTP protocol. One
of its core strengths lies in its dispatcher interface, which simplifies the complexity of
making HTTP calls at scale. Developers define how requests are handled using the
dispatch method, providing fine-grained control over request execution.
Fastify
Fastify, on the other hand, provides a powerful feature for performing networkless
calls through its .inject() method. This method allows a request to be simulated
as if it came from the network, while actually being processed entirely in-memory.
The Fastify server performs all routing, validation, and response handling internally—
without needing a real network layer.
208
Using Watt
Let’s explore how to implement a modular monolith architecture using Watt, and how to
build scalable applications with minimal boilerplate by leveraging Platformatic’s built-in
capabilities.
With Watt, you can structure your Node.js applications into cleanly separated services
while running them efficiently in a single runtime. This approach gives you the benefits
of modularity and isolation without the operational overhead of full microservices.
Project Structure
watt/
├── .env
├── watt.json
├── pnpm-workspace.yaml
├── services/
│ ├── composer/
│ │ ├── platformatic.json
│ ├── orders/
│ ├── platformatic.json
│ ├── plugins/
│ │ ├── auth.js
│ │ ├── config.js
│ │ ├── db.js
│ │ ├── payment.js
│ ├── routes/
├── orders.js
This structure embraces a monorepo layout with multiple services under a single
codebase, each with its own configuration and domain logic. The services are
automatically detected and orchestrated by Watt, minimizing setup and maximizing
modularity.
Each service can define its own routes, plugins, and configuration—yet run efficiently
as part of the same process or runtime, thanks to Watt’s smart threading model and
internal HTTP mesh.
209
watt.json
{
“$schema”: “https://schemas.platformatic.dev/@platformatic/
runtime/2.55.0.json”,
“entrypoint”: “composer”,
“watch”: true,
“basePath”: “{PLT_BASE_PATH}”,
“autoload”: {
“path”: “services”
},
“server”: {
“hostname”: “{PLT_HOSTNAME}”,
“port”: “{PLT_PORT}”
},
“logger”: {
“level”: “{PLT_LOGGER_LEVEL}”
}
}
210
services/composer/platformatic.json
{
“$schema”: “https://schemas.platformatic.dev/@platformatic/
composer/2.55.0.json”,
“server”: {
“healthCheck”: true
},
“composer”: {
“services”: [
{
“id”: “orders”
},
{
“id”: “products”
},
{
“id”: “users”
}
],
“refreshTimeout”: 1000
},
“watch”: true
}
211
services/orders/platformatic.json
This is a service configuration, where we define plugins and routes, in this case the
orders service. Routes and plugins follow the same pattern and implementation as in
monoliths and microservices, and that’s all you need to implement for each service.
{
“$schema”: “https://schemas.platformatic.dev/@platformatic/
service/2.55.0.json”,
“service”: {
“openapi”: true
},
“watch”: true,
“plugins”: {
“paths”: [
{
“path”: “./plugins/config.js”
},
{
“path”: “./plugins/auth.js”
},
{
“path”: “./plugins/db.js”
},
{
“path”: “./plugins/payment.js”
},
{
“path”: “./routes/orders.js”,
“routePrefix”: “/orders”
}
]
}
}
212
watt.json: Central Runtime Configuration
The main configuration file, watt.json, defines how Watt runs and orchestrates your
services. It automatically:
Sets the entry point of the application (typically the Composer service)
Each individual service includes its own platformatic.json, where you declare its
plugins, routes, and specific configuration.
The Composer service acts as the orchestration layer of your application. It plays a
central role in managing the lifecycle and communication of your services:
• Serves as the entry point for the entire application (as defined in watt.json)
• Automatically discovers and manages services within the system
• Acts as a unified API gateway, exposing a central HTTP interface
• Handles service registration, health checks, and routing
• Enables seamless service-to-service communication
213
Environment Variables
This unified approach eliminates the need for manual .env handling in each service,
simplifying configuration management and improving consistency across environments.
Built-in Functionalities
Watt offers a rich set of built-in capabilities out of the box, significantly reducing the
need for custom infrastructure code and boilerplate.
Logging
• Structured JSON logging with configurable log levels
• Uniform logging format across all services
• Context-aware request logging for easier debugging and tracing
OpenAPI Documentation
• Automatic generation of API documentation
• Built-in support for interactive Swagger UI
• Schema validation for incoming requests
Health Checks
• Built-in service health monitoring
• Automatic health check endpoints for integration with orchestration
tools like Kubernetes
Hot Reloading
• File watching in development mode
• Automatic service restarts on code changes
214
Advantages Over Traditional Approaches
Compared to traditional monoliths or manually configured microservices, Watt offers a
number of key benefits:
• Consistent architecture across services
• Centralized and declarative configuration
• Automatic service discovery
• Built-in observability and monitoring
• Simplified deployment (including Docker support)
• Reduced maintenance burden and boilerplate code
Wrapping Up
Watt improves Node.js scalability by executing multiple services as worker threads within
the same process, addressing the traditional single-threaded constraints of Node.js.
By allowing services to run in separate worker threads, Watt enhances CPU utilization,
reduces over-provisioning, and lowers infrastructure costs, all while maintaining the
simplicity of a modular architecture. This makes it particularly valuable for developers
looking to scale applications efficiently without introducing unnecessary complexity.
By integrating Watt into a Node.js project, developers can take full advantage of modular
design without sacrificing performance. Instead of relying on complex clustering or
external load balancing, Watt enables a more streamlined, resource-efficient execution
model that complements the modular principles discussed in this chapter.
215
7
Running Node.js
in the Cloud
A deep dive into deployment options and
optimization techniques for Node.js
1 Node.js and Docker: The First Step to Cloud Deployments 219
Running Node.js applications in the cloud can provide several advantages over
conventional deployments, including standardization, improved performance, and
monitoring.
Scalability
Does the platform provide different scaling solutions, like vertical and
horizontal, based on runtime data?
Elasticity
Will resources be automatically increased to meet demand, without
intervention?
Cost-efficiency
Is the solution pay-as-you-go, increasing based on use, or a flat fee?
Reliability
What are the reliability guarantees, and how can they be ensured?
This chapter explores running Node.js in the cloud, the costs and benefits of different
deployment solutions, and optimization techniques for Node.js.
218
7.1 Node.js and Docker: The First Step to Cloud Deployments
By using Docker, a Node.js application can be packaged with all required dependencies,
configurations, and runtime settings, ensuring consistent execution regardless of the
underlying infrastructure.
219
Use a .dockerignore file to exclude unnecessary files and directories
(such as node_modules, .git, tests, and documentation) from being
copied into the image, as well as leaking secrets.
Let’s now look at different Dockerization strategies based on the nature of the
application.
We’ll explore three common scenarios: a plain JavaScript application with no native
dependencies, a TypeScript application that requires a build step, and an application
that includes native addons, which require compilation during installation.
220
7.2 TypeScript Application
In the final production stage, only the compiled output and required runtime
dependencies are copied, resulting in a smaller, more secure image
WORKDIR /usr/src/app
ENV NODE_ENV=production
COPY package.json .
COPY package-lock.json .
COPY .npmrc .
COPY tsconfig.json .
FROM node:22.10.0-slim
USER node
WORKDIR /usr/src/app
ENV NODE_ENV=production
ENV APP_HTTP_PORT=3000
EXPOSE ${APP_HTTP_PORT}
221
7.3 Application with native addons
For Node.js applications that rely on native addons, such as image processing libraries
like sharp or prisma, it is recommended to use a multi-stage Docker build. In this
approach, the build stage includes all development dependencies required to compile
native modules. The final production stage includes only the compiled output and the
necessary runtime dependencies, resulting in a smaller and more secure image.
Some libraries, such as sharp, provide precompiled binaries that automatically match
the deployment platform. Others, like prisma, compile native components during
installation, which requires that the build environment closely match the production
environment to avoid runtime incompatibilities.
There are two main base image options for applications with native addons:
222
A common best practice is to use the full node:slim image during the build stage and
switch to a smaller compatible image, either node:slim or node:alpine, depending
on dependency support, for the runtime stage.
WORKDIR /usr/src/app
COPY package.json .
COPY package-lock.json .
COPY .npmrc .
FROM node:22.10.0-slim
USER node
WORKDIR /usr/src/app
ENV NODE_ENV=production
ENV APP_HTTP_PORT=3000
EXPOSE ${APP_HTTP_PORT}
223
7.4 Pushing the Docker Image to a Container Registry
Once the Docker image is built, it needs to be tagged and pushed to a container
registry. From a container registry, the image can be deployed. Commonly used
container registries include: Docker Hub, Amazon Elastic Container Registry (ECR),
Google Container Registry (GCR), and Github Container Registry (GHCR).
Choosing the right cloud deployment model depends on scalability needs, cost
considerations, and operational complexity.
Azure Functions
Provides event-driven, serverless execution for Node.js applications.
224
Benefits of PaaS:
• Simplifies deployment and reduces operational overhead.
• Built-in scaling and load balancing.
• No need to manage underlying servers.
Challenges of PaaS:
• Opinionated and lacking flexibility.
• Higher monetary cost.
IaaS solutions provide virtual machines (VMs) and networking resources, allowing
developers to deploy Node.js applications with full control over the environment.
Common IaaS providers include:
Benefits of IaaS:
• Greater flexibility in configuring the server environment.
• Full control over security policies and networking.
• Suitable for applications with specific performance or compliance requirements.
Challenges of IaaS:
• Requires manual provisioning, configuration, and scaling.
• Increased operational complexity compared to PaaS solutions.
225
Serverless Functions
Serverless functions are a type of PaaS which have a narrow application scope. They
are best suited for workloads that run long when there is an active user or workloads
that can be highly concurrent and are only required in short bursts.
Container Orchestration
Kubernetes is the most well-known orchestration system, there are a number of others
that perform similar functions. With container orchestration, you get a cross between
IaaS and PaaS. Common providers include:
Nomad (HashiCorp)
226
Benefits of Container Orchestration:
• Reduced operational overhead compared to IaaS.
• Increased flexibility compared to PaaS.
• Standardised resource management.
• Vast ecosystem of tools.
Containers can be thought of as the definition of the Operating System and application
being run. In Kubernetes, containers are run on Pods, which act like Virtual Machines.
Pods provide the compute, storage, and network resources for the containers. The
Pod interface provides a way for Kubernetes to create replicated sets and dynamically
scale vertically.
227
7.7 Deploying Node.js in Kubernetes
Kubernetes has become the standard for large teams as well as teams with large
portfolios.
Automated Scaling
Adjusts the number of running containers based on application relevant
metrics.
Self-healing
Restarts failed containers and replaces unhealthy pods.
Load balancing: Distributes traffic across multiple instances to prevent
bottlenecks.
Service Discovery
Simplifies networking between services using internal DNS and a well-
defined naming scheme.
Safe deployments
Deployment rollout is stopped if a pod is found to be failing, avoiding
broken applications getting into production.
Requirements
1.A hosted image that can be downloaded by Kubernetes. Docker Hub provides an
easy to use container registry.
2. A Kubernetes installation. For local development, k3d is an excellent tool. A working
cluster can be started with k3d cluster create demo -p “30303:30303”
228
Deploying
At the core, an application runs inside a container that is hosted by a Pod. A Pod is
a single instance and can be thought of as a Virtual Machine. A single instance of an
application is not helpful for scalability and resilience though.
To run multiple instances, a ReplicaSet is used. This resource controls the number
of running Pod by using a template to define the Pod and making sure a set number of
replicas are always available.
Setup
apiVersion: v1
kind: Namespace
metadata:
name: cloud-nodejs-demo
229
Save this file as namespace.yaml and apply to the cluster:
Deployment
The next step is to get an application up and running. The best practice for replicated
instances is to use a Deployment resource. This manages a ReplicaSet and allows
for simple update and removal strategies of Pod resources.
Create a file called deployment.yaml with the following content:
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo-app
namespace: cloud-nodejs-demo
labels:
app.kubernetes.io/instance: demo-app
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/instance: demo-app
template:
metadata:
labels:
app.kubernetes.io/instance: demo-app
spec:
containers:
- image: “docker.io/platformatic/cloud-nodejs-demo:latest”
name: demo-app
ports:
- containerPort: 3000 # Port the app is listening on
Make sure to replace the image with another name. Create the resource:
230
Make sure to replace the image with another name. Create the resource:
Access
Network access to the deployed application can become complicated very quickly.
The idea is to create a Service resource which acts as an in-cluster router to the
deployed pods. The simplest method is to use a NodePort which allows for defining a
port to access the application.
This isn’t a good long-term solution but does provide a quick way to verify the application
works as expected. A Running application is not necessarily a working application.
apiVersion: v1
kind: Service
metadata:
name: cloud-nodejs-demo
namespace: cloud-nodejs-demo
labels:
app.kubernetes.io/instance: demo-app
spec:
type: NodePort
selector:
app.kubernetes.io/instance: demo-app
ports:
- port: 3000
nodePort: 30303
231
The application access can be verified with a curl request:
Each cloud provider has a different way of providing public internet access to a
Deployment. All major providers support the Ingress resource though each cloud
provider will have slight variations in the annotations used.
For GCP:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: demo-app
annotations:
kubernetes.io/ingress.class: gce
spec:
rules:
- host: demo-app.example
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: demo-app
port:
number: 3000
232
For AWS:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: demo-app
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
spec:
ingressClassName: alb
rules:
- host: demo-app.example
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: demo-app
port:
number: 3000
For Azure:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: demo-app
annotations:
kubernetes.io/ingress.class: azure/application-gateway
spec:
rules:
- host: demo-app.example
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: demo-app
port:
number: 3000
233
Next level
To get Kubernetes deployments ready for production, a good first step is turning all
resource creation into templates using Helm.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $.Values.appName }}
annotations:
{{ if eq “gcp” $.Values.cloud }}
kubernetes.io/ingress.class: gce
{{- end }}
{{ if eq “aws” $.Values.cloud }}
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
{{- end }}
{{ if eq “azure” $.Values.cloud }}
kubernetes.io/ingress.class: azure/application-gateway
{{- end }}
labels:
app.kubernetes.io/instance: {{ $.Values.appName }}
spec:
rules:
- host: demo-app.example
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ $.Values.appName }}
port:
number: 3000
With different values.yaml files, any number of applications can be deployed with a
standardized configuration. A values file for this example would look like:
appName: demo-app
cloud: gcp
234
7.9 The Fundamental Mismatch Between Kubernetes and Node.js
Key Challenges:
Inefficient Scaling
CPU-based scaling can misinterpret Node.js workloads, triggering
unnecessary pod creations.
For Node.js applications, CPU utilization is not a useful metric because it does not
directly correlate with application load. The CPU can spike from application load,
garbage collection, or async tasks. By measuring only CPU for Node.js applications,
Kubernetes leans towards over provisioning resources.
A better metric to measure is the Event Loop Utilization (ELU). This metric provides a
direct view of how the event loop is operating. A high utilization, sustained for more
than a second, can indicate too heavy of a load and an increase in latency.
Measuring ELU requires the correct metrics to be exported and Kubernetes Custom
Metrics to be enabled. Using a tool like Platformatic’s Intelligent Autoscaler, can simplify
the process of setting up scaling while also using industry best practices.
235
Memory Management
Kubernetes does not cache DNS lookups by default, meaning every HTTP request
between microservices triggers a fresh DNS resolution.
Fix: Implement a local DNS cache on Kubernetes nodes to reduce unnecessary lookups
and improve response times.
236
7.11 Serverless Functions with Node.js
Pay-per-use
Charges are based on execution time, reducing costs for infrequent
workloads.
Automatic scaling
Functions scale up and down based on demand.
Cold starts
Initial function invocation can be slow due to resource allocation.
At near-zero scale, serverless might seem like an attractive option because you only
pay when traffic happens. However, the reality is that if you’re at that scale, you’re
also experiencing cold starts every time, making performance significantly worse. In
contrast, even a cheap, auto-scaled EC2 instance can provide better performance and
cost-effectiveness for most applications. Many bootstrapped startups skip serverless
entirely, opting for lightweight instances that offer more control and efficiency with
negligible cost differences.
237
The best approach is to build in a way that makes migration easy. Using something
like fastify-aws-lambda ensures that your code can be moved out of Lambda into
a normal server-based environment without major rewrites.
Starting with serverless can be fine if it encourages modularity, but as soon as your
application grows, you’ll likely want to migrate to something more efficient.
app.js
if (!product) {
reply.code(404)
return { message: ‘Product not found’ }
}
return product
238
} catch (err) {
request.log.error({ err }, ‘Error retrieving product’)
reply.code(500)
return { message: ‘Error retrieving product’ }
}
})
try {
const command = new PutCommand({
TableName: PRODUCTS_TABLE,
Item: { id: randomUUID(), ...product }
})
await dynamoClient.send(command)
reply.code(201)
return product
} catch (err) {
request.log.error({ err }, ‘Error creating product’)
reply.code(500)
return { message: ‘Error creating product’ }
}
})
await dynamoClient.send(deleteCommand)
return { message: ‘Product deleted successfully’ }
} catch (error) {
request.log.error(error)
reply.code(500)
return { message: ‘Error deleting product’ }
}
})
return app
}
239
lambda.js
Wrapping Up
Running Node.js applications in the cloud provides flexibility, scalability, and cost-
effectiveness. Choosing the right deployment strategy depends on the specific
requirements of a project:
• PaaS is ideal for rapid development and automatic scaling without infrastructure
management.
• IaaS provides full control but requires more configuration and maintenance.
• Serverless computing is best for event-driven applications with unpredictable
traffic.
• Kubernetes is a powerful solution for large-scale, containerized applications
requiring advanced orchestration but comes with added complexity.
By selecting the right cloud deployment model, developers can optimise performance,
cost, and operational efficiency for their Node.js applications.
240
How can we help?
241
8
Ensuring Scalability
and Resilience within
Node.js Applications
Nowadays, digital product user expectations are higher than ever, and online services
are expected to be available around the clock. This renders scalability and resilience not
just desirable qualities but essential requirements for successful Node.js applications.
Scalability refers to the ability of an application to handle increasing workload and user
demand without sacrificing performance, while resilience pertains to the application’s
ability to withstand and recover from failures or disruptions in its environment.
Similarly, resilience is equally crucial for Node.js applications, as even the most
meticulously designed systems are susceptible to failures, whether due to hardware
malfunctions, software bugs, or network issues. An application’s ability to gracefully
handle and recover from such failures is essential for maintaining service availability
and ensuring a seamless user experience.
This chapter looks at the core concepts and best practices for architecting and
developing robust, scalable Node.js applications that can withstand high traffic volumes
and unexpected events.
244
We’ll explore various architectural considerations that foster scalability and resilience.
By following the guidance in this chapter, you’ll equip yourself with the knowledge
and tools to build enterprise-grade Node.js applications that can handle ever-growing
demands while delivering exceptional user experiences.
Microservices Architecture
This promotes agility and flexibility, allowing Node.js applications to adapt to changing
requirements and scale components as needed without affecting the entire system’s
performance.
Asynchronous Programming:
Asynchronous programming minimizes the time spent waiting for I/O operations to
complete, maximizing resource utilization and improving overall application performance.
This approach is particularly beneficial for building scalable and resilient Node.js
applications that can handle large volumes of concurrent requests without becoming
overwhelmed.
245
import { writeFileSync } from ‘fs’
import { writeFile } from ‘fs/promises’
As traffic patterns change, so should your deployment. When load is high, you don’t
want your application to collapse under the stress. When load is low, you don’t want to
be paying for more resources than you need, leading to a massively inflated cloud bill.
You need to be prepared for horizontal scaling–that is, adding more servers to the
cluster running your application.
At times, you may want to reach for vertical scaling, deploying larger instances when
you need more headroom for average workloads, but typically, horizontal scaling is
what you’ll be reaching for more often and even automatically as adding more instances
doesn’t require working around inflight traffic.
Horizontal Scaling:
246
For example, scaling a Node.js application in Kubernetes with a HorizontalPodAutoscaler
would look something like this:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nodejs-app
spec:
replicas: 3
selector:
matchLabels:
app: nodejs-app
template:
metadata:
labels:
app: nodejs-app
spec:
containers:
- name: nodejs-app
image: your-dockerhub-username/nodejs-app:latest
ports:
- containerPort: 3000
resources:
requests:
cpu: “200m”
memory: “256Mi”
limits:
cpu: “500m”
memory: “512Mi”
env:
- name: NODE_ENV
value: “production”
---
apiVersion: v1
kind: Service
metadata:
name: nodejs-app-service
spec:
selector:
app: nodejs-app
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: LoadBalancer
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: nodejs-app-hpa
spec:
scaleTargetRef:
247
apiVersion: apps/v1
kind: Deployment
name: nodejs-app
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
While that can get you a good amount of the way to effective scalable workloads, it’s
important to understand that the Kubernetes HorizontalPodAutoscaler only operates
on high-level system metrics like cpu and memory usage.
Vertical Scaling/Sizing:
248
Deploying with auto-scalers
Whether it be with Kubernetes or with a cloud vendor, you should always prepare your
deployment systems with quick, painless, and ideally automated scaling in mind. Just
checking the box of auto-scaling on its own is not enough, as not all auto-scalers are
created equal. You want to understand what is the time-to-life for the environment you
target.
Is it a container orchestrator? How long does it take to schedule the workload and have
the service come online?
When reacting to scale you need to be sure you can react fast enough. If you have a
fast spike of traffic, you may not have time to scale up. In this situation, you may have
to plan a scale up ahead of an expected spike, or, if your spikes are less predictable,
you may need to over-provision enough to provide a buffer to take enough of the initial
climb to compensate for the spin up time for more instances.
At Platformatic, we built autoscaling into the Command Center to apply scaling decisions
to a Kubernetes cluster from a much deeper understanding of the performance
characteristics of your Node.js applications.
Load balancers are an essential component of horizontal scaling. If you are running in
the cloud, your provider likely already manages this. However, if you’re deploying on
your own hardware, you will need to make sure your traffic can be balanced effectively
over as many application instances as you have available.
If you’re running on Kubernetes, you’re likely going to rely on your Ingress for load
balancing, but there may be cases where you want to use per-service load balancers.
249
Traefik is a popular option for providing more control and visibility than the default
Ingress Controller. If you’re running on bare metal you may want to consider HAProxy.
Learn about what is available for your target environment and what your load-balancing
strategies are. Generally, a simple round-robin strategy that simply rotates between
each target instance and directs the single next request is sufficient. However, many
load balancers provide more complex strategies which can be more suitable to the
characteristics of your traffic profile.
8.3 M
onitor the health of your Node.js application with key
performance metrics
How will you know it’s time to scale if you don’t know your application is at risk in the
first place? You need to have useful monitoring signals in place to inform you at a
glance when the existing scaling strategies you have in place may be insufficient.
You can’t scale your way out of a memory leak, you can just slow it down. Similarly, you
can’t scale your way out of an app that’s failing half its requests and magnifying its own
traffic with clients applying their own exponential backoff strategies to keep retrying
failed requests.
You need to not just know the numbers for your application health, but to understand
them too. There’s a close relationship between CPU usage and event loop utilization,
for example. Understanding how your application behaves under different load patterns
is an important step to understanding how to scale it effectively.
To truly understand how your Node.js application is performing and ensure it delivers a
seamless user experience, you need to track a specific set of metrics.
1. Response Time
• Response time refers to the duration between when a request is
received by the server and when the corresponding response is sent
back to the client. Response time directly impacts user experience.
Faster response times lead to more satisfied users, while slower
response times can result in frustration and abandonment of the
application.
250
• Evaluation: Organizations should aim to monitor response time and
set performance targets based on user expectations and industry
standards. Continuous monitoring and optimization can help ensure
that response times remain within acceptable limits.
2. Throughput
• Throughput measures the number of requests processed by the
server within a given time period. Throughput provides insights
into the server’s capacity to handle incoming requests and its
overall efficiency. Higher throughput indicates better scalability and
performance.
3. Error Rate:
• Error rate refers to the percentage of requests that result in errors
or failures. Error rate reflects the stability and reliability of the
application. High error rates can indicate bugs, infrastructure issues,
or performance bottlenecks that need to be addressed.
4. CPU Utilization
• CPU utilization measures the percentage of CPU resources
consumed by a Node.js application. High CPU usage can indicate
excessive computational overhead, inefficient algorithms, or blocking
operations that degrade application performance.
251
• Evaluation: Monitoring RSS utilization helps identify memory-related
inefficiencies. Organizations should analyze trends, optimize memory
usage, and leverage tools like garbage collection tuning and load
balancing to prevent excessive memory consumption.
6. Memory spread (heap total vs heap used, old space vs new space)
Memory usage in Node.js is often monitored by tracking the amount of
heap memory in use (heap used) compared to the total available heap
memory (heap total). The heap is where Node.js stores objects and
variables during the execution of an application. Heap total refers to the
maximum amount of memory allocated to the heap, while heap used
tracks how much of that allocated memory is actively in use.
Evaluation: Monitoring heap total vs. heap used alongside Old Space
vs. New Space helps diagnose memory-related inefficiencies:
252
7. Event loop utilization
• Event loop utilization measures the percentage of time the Node.js
event loop is actively processing tasks compared to the time it spends
idling. The event loop is the heart of Node.js, handling all incoming
and outgoing operations. High event loop utilization indicates efficient
use of resources and minimal idle time. Conversely, low utilization
might suggest inefficiencies, such as waiting for external resources
or poorly structured code blocking the event loop.
*See Appendix A for a deep dive into the Node.js Event Loop
8.4 A
cting on metrics
253
instance when memory usage is at a moderate level may be premature,
especially if the application is still performing well overall. Instead,
organisations should focus on whether the memory consumption is truly
problematic, such as if it’s growing uncontrollably over time (indicating a
potential memory leak), or if it’s starting to hit the limits of the system’s
physical memory.
If the event loop is underutilised, meaning it’s not busy processing tasks,
scaling the application solely based on CPU metrics could be a misstep.
The application may be idle and not in need of additional resources.
Therefore, it’s essential to also consider the Event Loop Utilisation (ELU)
metric. If the event loop is busy and responding to requests, the system
is likely functioning optimally, even if the CPU is at high capacity. Only
scale up or take action if ELU reveals that the application is actually
experiencing a bottleneck or significant delay in processing.
Standard monitoring metrics, such as CPU usage and RSS (Resident Set Size), are often
insufficient for effective troubleshooting of Node.js applications. These basic metrics
provide a high-level view of system performance, but they lack the granularity and
context necessary to fully understand the health of a Node.js application, especially in
more complex environments.
Monitoring Node.js applications often requires cobbling together insights from multiple
tools and custom dashboards, juggling metrics like CPU usage, memory consumption,
and latency. This makes it difficult to pinpoint the root cause of slowdowns and
unresponsive applications.
254
Imagine a platform with numerous microservices; a fragmented view might reveal high
CPU usage, but without context, identifying the culprit remains a guessing game.
Let’s take a deeper look at some of the challenges that arise when monitoring Node.js
applications:
Asynchronous Nature
Node.js operates on an asynchronous, event-driven model, which can
make traditional monitoring techniques less effective. Monitoring tools
must be capable of tracking asynchronous operations and event loops
to provide accurate performance insights.
Complex Ecosystem
Node.js applications often rely on a wide range of dependencies,
frameworks, and microservices, making it challenging to monitor the
entire ecosystem comprehensively.
Memory Leaks
A memory leak occurs when memory is allocated but not properly
released, leading to increased memory usage over time. Unlike a
memory usage issue, which can occur due to high traffic or data inflow
without necessarily indicating a problem, a memory leak causes steady,
uncontrolled growth that can eventually crash the application.
To distinguish between the two, it’s crucial to use metrics like throughput
(request volume) and event loop utilisation (ELU). If memory usage grows
alongside throughput and the event loop remains responsive, it’s likely
expected. However, if memory usage increases without corresponding
traffic and the event loop is blocked, this could indicate a leak.
255
8.6 OpenTelemetry Tracing
OpenTelemetry Tracing builds upon the foundation laid by the Dapper paper, a seminal
work published by Google in 2010. Dapper introduced the concept of distributed
tracing, a revolutionary approach to monitoring performance in complex, microservices-
based architectures. Traditional monitoring tools often struggled to track requests that
spanned multiple services, making it difficult to pinpoint performance bottlenecks.
Dapper’s innovation involved assigning a unique identifier (trace ID) to each request
and propagating it throughout the entire request lifecycle. This allowed developers
to trace a request’s journey across various microservices, identify potential delays or
errors at each hop, and gain a holistic understanding of application performance.
The impact of the Dapper paper was significant. It not only provided a practical
solution for distributed tracing but also inspired the development of numerous open-
source tracing tools like Zipkin and Jaeger. Today, OpenTelemetry builds upon these
advancements by establishing a vendor-neutral approach to collecting and exporting
telemetry data, including traces initiated by the Dapper tracing model.
Another equally important paper, which laid the groundwork for Dapper, was the 2007
X-Trace paper titled “A Pervasive Network Tracing Framework”.
Dapper’s paper was the adaptation of this model, made specific to HTTP with the use
of HTTP headers as the transport mechanism.
Popular tools:
Zipkin
An open-source distributed tracing tool that helps you trace requests
across microservices. Zipkin collects trace data from instrumented
applications and backend systems, providing detailed visualisations of
request lifecycles. It is especially useful for identifying latency issues and
pinpointing performance bottlenecks within your Node.js applications.
256
Jaeger
A popular distributed tracing solution that offers a scalable, user-friendly
interface for analysing trace data. Jaeger collects detailed traces
and visualises how requests flow through your application, helping
developers identify potential delays, errors, and inefficiencies.
Watt comes with telemetry out of the box, making it easier to fulfill essential non-
functional requirements such as observability and system health. These features are
integrated at the framework level, requiring minimal configuration while offering full
control when customization is needed.
Monitoring provides insight into how the system behaves over time by collecting and
aggregating quantitative data. Tracing adds visibility into the lifecycle of individual
requests, highlighting latencies and dependencies across service boundaries.
Health checks ensure that the application signals its status correctly to orchestrators
and load balancers, helping to maintain reliability and uptime.
In this section, we will explore how Watt exposes metrics compatible with Prometheus,
how it integrates with OpenTelemetry for distributed tracing, and how to enable and
customize health endpoints. These tools are essential for building confidence in the
behavior of your application in production environments and for proactively detecting
anomalies or regressions.
257
Monitoring
You can also define custom application-specific metrics if needed. No application code
is required to start collecting standard metrics. Additional options include setting a
custom hostname, enabling authentication, and attaching global labels to all exported
metrics. It’s also possible to define custom metrics directly in the application code to
capture domain-specific insights.
{
“$schema”: “https://schemas.platformatic.dev/@platformatic/
runtime/2.55.0.json”,
“metrics”: true
}
Prometheus Configuration
To collect and visualize these metrics, you can run Prometheus locally or in Docker.
Below is a minimal prometheus.yml configuration to scrape metrics from a Watt
application:
global:
scrape_interval: 15s
scrape_timeout: 10s
evaluation_interval: 1m
scrape_configs:
- job_name: ‘platformatic’
scrape_interval: 2s
metrics_path: /metrics
scheme: http
basic_auth:
username: platformatic
password: mysecret
static_configs:
- targets: [‘192.168.69.195:9090’]
labels:
group: ‘platformatic’
258
In this configuration:
• The targets field should point to the IP and port where Watt exposes metrics.
• Basic authentication is configured to match the Watt telemetry settings.
• The IP address must be reachable from the Prometheus container. If Prometheus runs
in Docker on the same host, use the host’s LAN IP, not localhost or 127.0.0.1.
version: “3.7”
services:
prometheus:
image: prom/prometheus:latest
volumes:
- prometheus_data:/prometheus
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
command:
- ‘--config.file=/etc/prometheus/prometheus.yml’
ports:
- ‘9090:9090’
volumes:
prometheus_data: {}
docker-compose up -d
Then open http://localhost:9090 in your browser. You should see the Prometheus UI,
and you can query metrics like:
{group=”platformatic”}
Refer to the Prometheus documentation for details on how to build queries and
understand metric types.
259
Grafana Configuration
version: “3.7”
services:
prometheus:
image: prom/prometheus:latest
volumes:
- prometheus_data:/prometheus
- ./prometheus.yml:/etc/prometheus/prometheus.yml
command:
- ‘--config.file=/etc/prometheus/prometheus.yml’
ports:
- ‘9090:9090’
grafana:
image: grafana/grafana:latest
volumes:
- grafana_data:/var/lib/grafana
environment:
- GF_SECURITY_ADMIN_PASSWORD=secret
depends_on:
- prometheus
ports:
- ‘3000:3000’
volumes:
prometheus_data: {}
grafana_data: {
docker-compose up -d
260
Open Grafana at http://localhost:3000, log in with the default user admin and the
password you set (secret), then:
You can now create dashboards and add panels using Prometheus queries. Platformatic
metrics will be available and can be used to monitor HTTP performance, CPU, memory
usage, and any custom metrics you define in your application.
Tracing
Watt supports OpenTelemetry integration by default, allowing you to export trace data
to any OTLP-compatible backend, such as Jaeger, Tempo, or Honeycomb. Tracing
helps you understand the flow of requests through your system, identify performance
bottlenecks, and gain visibility into distributed interactions.
Watt automatically instruments its HTTP layer, but more specific telemetry
instrumentation can be configured depending on the services used within your
application. For example, if a service is built using express, you can enable the @
opentelemetry/instrumentation-express package to capture detailed tracing
data for that part of the system.
In addition to automatic instrumentation, Watt allows you to add custom tracing for
specific operations using the @opentelemetry/api package. This is useful when
you want to trace internal logic that isn’t automatically captured, such as database
queries, third-party API calls, or computationally expensive functions.
By manually creating and managing spans, you can enrich your trace data with
meaningful, application-specific details.
261
8.8 A Sample watt.json Configuration to Enable Tracing
{
“$schema”: “https://schemas.platformatic.dev/@platformatic/
runtime/2.55.0.json”,
“telemetry”: {
“serviceName”: “example”,
“exporter”: {
“type”: “otlp”,
“options”: {
“url”: “http://localhost:4318/v1/traces”
}
}
}
}
This configuration sets up Watt to export trace data to a locally running Jaeger instance
using the OTLP HTTP protocol.
Here’s a basic docker-compose.yml file to run Jaeger locally with OTLP support
enabled:
version: ‘3.7’
services:
jaeger:
image: jaegertracing/all-in-one:1.48
container_name: jaeger
ports:
- “16686:16686” # Jaeger UI
- “4318:4318” # OTLP HTTP receiver
networks:
- watt-network
networks:
watt-network:
262
Start Jaeger with:
docker-compose up -d
Verifying Traces
watt start
curl http://localhost:3000/images/a-beautiful-sunset
http://localhost:16686/
Select the service name (example) from the dropdown and search for recent traces. You
should see the request you just made, along with the corresponding spans generated
by Watt.
263
The trace of a specific HTTP request can be retrieved, highlighting a particular call.
This facilitates a thorough examination of each phase of the request’s lifecycle, from
initiation through internal processing to the final response.
264
Health
Platformatic provides a built-in API for implementing readiness and liveness checks
through its metrics server. When telemetry is enabled, the metrics server also exposes
two dedicated endpoints for health monitoring:
• /ready – indicates whether the service is running and ready to accept traffic.
• /status – verifies whether all dependent services in the stack are reachable.
Let’s walk through a sample application that includes monitoring, tracing, and health
checks.
We’ll implement a hypothetical service responsible for fetching images and user profiles.
The project follows a modular monolith architecture and uses Platformatic Watt as its
runtime environment.
app/
├── watt.json
├── services/
├── main/ # Platformatic Composer
│ ├── platformatic.json
│ ├── ...
│ ├── package.json
├── images/ # Images service
│ ├── index.js
│ ├── db.js
│ ├── storage.js
│ ├── platformatic.json
│ ├── package.json
├── profiles/ # Profiles service
├── ...
├── package.json
265
watt.json
Metrics are exposed on port 9090 at the /metrics endpoint, making them easy to
scrape using tools like Prometheus. Tracing data is exported to a Jaeger service via
the OTLP protocol.
{
“$schema”: “https://schemas.platformatic.dev/@platformatic/
runtime/2.55.0.json”,
“entrypoint”: “main”,
“autoload”: {
“path”: “services”
},
“metrics”: {
“endpoint”: “/metrics”,
“port”: 9090
},
“telemetry”: {
“serviceName”: “example”,
“exporter”: {
“type”: “otlp”,
“options”: {
“url”: “http://localhost:4318/v1/traces”
}
}
}
}
• The health check is overridden to verify that both the database and the storage
service are operational.
• A custom Prometheus counter is added to track the number of image requests
received by the service.
services/images/index.js
The images service is automatically instrumented for telemetry, but it can also
customize the healthcheck, in this case to ensure the database and the storage is
properly working, and also it adds a custom metric to count the images requests
266
import { trace } from ‘@opentelemetry/api’
import fastify from ‘fastify’
import db from ‘./db.js’
import storage from ‘./storage.js’
globalThis.platformatic.setCustomHealthCheck(async () => {
status = await Promise.all([
checkDatabase(),
checkStorageService()
])
return status
})
if (!imageId) {
span.setAttribute(‘http.status_code’, 404)
span.addEvent(‘Image not found’)
reply.code(404)
span.end()
267
return { error: ‘Image not found’ }
}
span.setAttribute(‘http.status_code’, 200)
span.setAttribute(‘http.content_type’, image.contentType)
span.setAttribute(‘response.size’, image.data.length)
span.end()
return image.data
} catch (error) {
span.recordException(error)
span.setStatus({ code: trace.SpanStatusCode.ERROR })
span.end()
throw error
}
})
})
return app
}
Remember, the best monitoring solution depends on your specific needs and budget.
Consider factors like the scale of your application, desired level of detail, and team
expertise when making your choice.
268
How can we help?
The Platformatic Command Center offers a single, intuitive dashboard that gives teams
a real-time overview of all their Node.js applications and services so they can see
resource utilization, deployment statuses, and key metrics like CPU usage, memory
consumption, and latency in one place. By centralizing all application data and activity
in one place, the Command Center provides a comprehensive audit trail. This facilitates
compliance efforts and helps trace actions for troubleshooting purposes to minimize
user impact.
Scale intelligently
269
8.10 So, how do I build a resilient Node.js app?
To maintain site uptime and handle traffic spikes effectively, you must anticipate
demand fluctuations, optimize resource usage, and enable seamless scaling.
270
Connection Pooling for Resource Optimization
Opening a new database connection for each request is inefficient
and may overwhelm the database. Use connection pooling to manage
database load efficiently, balancing performance and resource allocation.
This approach also helps mitigate head-of-line blocking, ensuring fair
and efficient query execution.
For database caching, tools like async-cache-dedupe help manage cache expiration
and invalidation, while Mercurius-cache optimizes GraphQL performance.
271
3. Enabling Adaptability: Scaling to Meet Demand
Implement Auto-Scaling
Horizontal scaling—adding more instances—is the preferred approach
for handling increased load. Configure auto-scalers in Kubernetes or your
cloud environment to dynamically adjust resource allocation. However,
not all auto-scalers react quickly enough to traffic spikes; pre-scaling
before anticipated surges can prevent outages.
Wrapping Up
By following these best practices, you can minimize downtime, optimize performance,
and build a scalable, resilient Node.js application that meets the demands of modern
web traffic.
272
9
Using Platformatic
to Solve Node
for Enterprise
Let’s build better Node.js
applicatioans — together.
Using Platformatic to Solve
9
Node for Enterprise
Let’s build better Node.js applications — together.
As we’ve explored throughout this ebook, Node.js is a robust foundation for building
fast, modern applications — but when you’re operating in a large-scale, high-stakes
environment, things get complicated quickly.
Platformatic is the result of years spent helping teams at Fortune 500s and high-
growth companies architect, operate, and scale mission-critical Node.js systems. It’s
built from our open-source work (Fastify, Pino, Node.js core), hardened in production,
and designed specifically to solve the recurring challenges enterprise teams face.
Rather than duct-taping together tools not designed for Node, Platformatic gives you
a unified platform that helps you:
Improve observability
With Node.js-specific metrics built-in — no need to set up Prometheus
or wrangle OpenTelemetry just to see how your app is doing.
276
Understand deployment risks
With full API dependency mapping, so you know exactly what will break
— and which teams are affected — before hitting “deploy”.
Cache intelligently
with a first-of-its-kind system that uses real-time machine learning to
automate caching strategies — eliminating endless meetings, manual
config, and costly trial-and-error cycles.
At its core, Platformatic isn’t just another platform — it’s the culmination of lessons
learned from building, breaking, and scaling Node.js applications for over a decade.
You’ve already chosen Node.js for its performance and flexibility. Platformatic helps
you keep that speed as you scale, without paying the hidden costs of complexity,
fragility, or surprise cloud bills.
277
A
Appendix A
A4 What Happens When All Requests Arrive At The Same Time? 284
The event loop is core to the performance of Node.js, helping it to perform asynchronous
and non-blocking operations by leveraging the kernel.
What can go wrong? In this article, we demonstrate a way to stall the event loop and
your application as a result.
How could we protect against such a problem? A thorough and accurate understanding
of event loops is beneficial for developers to grasp the inner workings of Node.js better.
This article explains the event loop, its importance, and best practices. It also further
explains the mathematics behind synchronous response processing and the nitty-gritty
of event loop utilization.
280
A.1 Why is the event loop important in Node.js?
The event loop is important in Node.js for several reasons. First, it forms the pillar
of Node’s asynchronous architecture, allowing Node to handle multiple concurrent
operations without the need for multi-threading efficiently.
Second, the event loop contributes to the performance and resource efficiency of
Node.js.
The event loop’s non-blocking nature allows developers to write code that can be
executed on the available system resources, helping it provide fast responses.
Compared to the thread-based model, the event loop model has a significant advantage:
it enables the CPU to handle many more requests at once. It is also more performant
than the thread-based model, using a lot less memory to execute for the kernel.
• Timers
• Pending callbacks
• Idle/prepare
• Poll
• Check
• Close callbacks
• Incoming connections and
data
The first phase– the timers– are callbacks registered with ‘setTimeout()’ or
‘setInterval()’.
They also allow us to monitor the event loop with the option to schedule data, ultimately
281
offering a good way to check if an event is idle. The event loop then executes expired
timers and checks for pending callbacks again.
The I/O callbacks are checked first in the poll phase, followed by the ‘setImmediate()’
callbacks and microtasks. Node.js also has a special callback, the process.
nextTick(), which executes after each loop phase. This callback has the highest
priority.
During the poll phase, the event loop looks for events that have completed their
asynchronous tasks and are ready to be processed.
We then move to the check phase, during which the event loop executes all the
callbacks registered with ‘setImmediate().
Close callbacks are associated with closing network connections or handling errors
during I/O events. The event loop will then look for scheduled timers.
The loop then continues, keeping the application responsive and non-blocking.
When a request comes in Node.js, it is processed synchronously, with the response then
undergoing a similar process. However, when a request needs to call the database, it
runs asynchronously.
This means that for every request, there are 2 synchronous processes and one
asynchronous process. Typically, the response time can be calculated from the formula
below:
282
For instance, if a request takes 10ms of synchronous processing time and 10 ms of
asynchronous processing time, the total response time will be:
2(10) + 10 = 30ms.
*Note: The synchronous part of the initial call and the synchronous part of the callback
are independent of each other and could have different times.
To calculate the total number of requests serviceable by one CPU can be calculated
by:
1000ms/(10ms*2) = 50
The I/O wait is not considered because the event loop runs synchronously.
283
A.4 What Happens When All Requests Arrive At The Same Time?
If, for instance, a server receives three requests at once, how long it will take to process
the last request?
The first request is processed while the second and third requests are queued. The
second and third requests are then processed in the order they arrived, waiting for the
preceding request to finish processing.
The processing time for each of the requests using the standard formula will be 30ms,
50ms and 70ms respectively, with the event loop running synchronously.
To calculate the response time for the last request, irrespective of the number of
requests, you can apply the formula:
When we receive 100 requests, you can calculate how long it will take to receive any
of the responses.
A possible solution to reducing this execution time is by scaling your servers based
on the CPU usage: however, spawning new servers takes time and it often result in
underutilized resources because that there can be available capacity in your system
despite 100% utilization.
The reason for this is simple: Node.js runs on multiple threads, with a garbage collector
and the CPU optimizer running separately.
This means that within Node.js, there can be a large amount of free CPU before anything
starts to slow down significantly.
284
A.5 The Event Loop Delay
Event loop delays are measurable, meaning that developers can track when an event
should fire and when it actually fired. To get an idea of how this works, you can clone
this repo locally and run the code. In this repo, the loopbench.js file contains the
following code:
const EE = require(‘events’).EventEmitter
const defaults = {
limit: 42,
sampleInterval: 5
}
function loopbench(opts) {
opts = Object.assign({}, defaults, opts)
result.delay = 0
result.sampleInterval = opts.sampleInterval
result.limit = opts.limit
result.stop = clearInterval.bind(null, timer)
return result
function checkLoopDelay() {
const toCheck = now()
const overLimit = result.overLimit
result.delay = Number(toCheck - last - BigInt(result.sampleInterval))
last = toCheck
function now() {
return process.hrtime.bigint() / 1000000n
}
}
module.exports = loopbench
285
The example.js file contains the code below:
loopbench.on(‘load’, function () {
console.log(‘max delay reached’, loopbench.delay)
})
function sleep(msec) {
let i = 0
const start = Date.now()
while (Date.now() - start < msec) { i++ }
return i
}
if (loopbench.overLimit) {
res.statusCode = 503 // Service Unavailable
res.setHeader(‘Retry-After’, 10)
}
res.end()
}
server.listen(0, function () {
const req = http.get(server.address())
setTimeout(function () {
console.log(‘overLimit after load’, loopbench.overLimit)
const req = http.get(server.address())
loopbench.stop()
server.close()
}).end()
}, parseInt(res.headers[‘retry-after’], 10))
}).end()
setImmediate(function () {
console.log(‘delay after active sleeping’, loopbench.delay)
})
sleep(500)
})
286
The example.js file contains the code below:
287
A.6 Event Loop Utilization
Event loop utilization (ELU) refers to the cumulative duration of time the event loop has
been both idle and active as a high-resolution milliseconds timer. We can use it to know
if there is “spare” capacity in the event loop.
ELU is a metric to monitor the amount of time spent in the event loop utilizing the CPU,
and can be read straight from libuv - the C library that Node.js uses to implement the
event loop.
You can compute ELU using the perf_hooks library. This will return a decimal between
0 and 1, which tells you how much of the event loop was used.
In Fastify, one of the fastest Node.js web frameworks, there is an automatically set up
module called @fastify/under-pressure. You can use it to specify the max event loop
delay, the memory and the event loop utilization.
When the package receives multiple requests after a certain time, the event utilization
goes out of the limit at 0.98s. After this point, any request that comes in gets a response
status code of 503.
Imagine having multiple requests, the event loop could have accumulated over 2
seconds of delay. A user might not find it comfortable to wait that long. In this case, you
can return a response to let the user know that the server will not return to the request.
To begin with, clone this repo, navigate into the thrashing directory, and find the server.
js file which starts the server.
node server.js
288
Then, in another terminal, run the command to emulate 50 connections for 10 seconds
to your server:
From the output above, the latency is slightly above 1 second and the average request
per second is 3 requests.
node server-protected.js
289
Then in another terminal, run the command to emulate the 50 connections to your
server in 10 seconds. This time, you have a different result as shown below.
Here we can see that we got a lot more requests– 19k, compared to 527 in the first
instance. Here, we got 96 successful requests compared to 31 using the unprotected
server.
The function of the under-pressure package is evident by the number of 503 response
statuses. The latency is also superior, with 338ms.
The server-load-aware.js file is a slight upgrade because it can even tell if the server is
under pressure, offering you more control over what you want your server to do when
it is under pressure and when it is not.
When we start our server load and run the demo again, this time we will obtain better
numbers.
290
Here, the server can handle more requests per second, compared to the previous two
instances. In this case, the 200 response statuses is the highest compared to the other
two instances which we looked at. The latency time is also quite low.
The biggest trade-off here is that the server sends cached data rather than returning a
503 response status. In this way, it can handle a lot more traffic and requests.
291
Best Practice for Event Loops
You can check out Piscina here. It creates a pool of worker threads,
which can process many tasks in parallel. It also gives you an idea of
how many jobs are queuing to be processed, offering users a better
view of what is happening within their server.
The API provides options such as ttl, which specifies the maximum
time an entry can live. The stale option specifies the time after which
the value is served from the cache after it has expired.
292
It also provides a memory option that defaults to storage and is compatible
with Redis. The size of this memory option can also be set.
Async-cache-dedupe
This means that the data your API needs can be accessed quickly
without needing to repeatedly fetch this data from slower sources, like
databases, every time.
293
P L A T F O R M A T I C
Node
Make