Editor’s note: This article was last updated by Muhammed Ali on 14 August, 2024 to cover new advancements in tech that had happened since the piece’s original 2020 date.
Node.js is a solution for executing JavaScript code outside a browser. The versatility and flexibility of JavaScript on the server side enable you to develop highly performant applications.
In this tutorial, we’ll explore how to improve Nnode.js performance and show how it can help you achieve better results with fewer resources. We’ll focus primarily on caching, using a load balancer and WebSockets, and monitoring your application. By the end of this guide, you’ll have the tools and approaches you need to improve the Node.js performance of your app, which will perform well at scale.
On the front end, it’s imperative that whatever is shipped to the browser is as small as possible. This especially includes images, JavaScript, and CSS files. The process that makes this possible involves module bundlers (e.g., webpack, Parcel, Rollup) and task runners (e.g., Gulp, Grunt, etc.).
Module bundlers are build tools for processing groups of modules and their dependencies into a file or group of files. Under the hood, this all happens using Node.js.
The output of such minification can then be deployed to production. The minification process can vary depending on the tool you use, but for the most part, you can use the standardized format for code modules.
This allows for complex transforms, such as shortening multicharacter variable names or using a shorter syntax that’s equivalent to the original code and combining several JavaScript files into one to reduce the number of network requests.
This also applies to CSS minification; the extra whitespace and comments are removed to help the browser parse it faster.
In the context of reducing browser requests during page load, CSS is no different when it comes to minification.
CSS preprocessors such as PostCSS, Sass, and LESS provide variables, functions, and mix-ins to simplify the maintenance of CSS code and make refactoring less challenging.
Furthermore, they compile all files into a single .css file, which reduces the number of round trips the browser has to make to serve the file.
With modern tooling that runs on Node.js, such as the aforementioned bundlers, scoped CSS names can be converted into global unique names. Now loading a CSS module to the local scope of your component is as simple as requiring or importing it like you would with any other JavaScript module.
Another good way to improve Node.js performance is by reducing the size of images. Generally speaking, the lighter your images, the better. You might want to use compressed images or serve different images, depending on the device. One example that comes to mind is Gatsby, which is powered by Node.js behind the scenes and has a slew of plugins that leverage Node, some of which are specifically designed to transform images at build time into smaller ones and serve them on demand.
HTTP/3 represents the first major upgrade to the Hypertext Transfer Protocol since HTTP/2, and it brings substantial performance improvements.
HTTP/3 runs on QUIC (Quick UDP Internet Connections), a new transport protocol designed to enhance speed and reliability, particularly for mobile-heavy internet usage. This update addresses several performance bottlenecks inherent in previous versions of HTTP. Features of HTTP/3 include:
Mitigating Head-of-Line Blocking
HTTP/3 runs over the User Datagram Protocol (UDP) instead of the Transmission Control Protocol (TCP). One of the key advantages of UDP is that it mitigates the issue known as head-of-line blocking. In TCP, if a single packet is lost or arrives out of order, all subsequent packets must wait, slowing down the entire connection.
Faster Connection Establishment
QUIC combines the cryptographic and transport handshakes into one, reducing the time required to establish a connection.
Zero Round-Trip Time (0-RTT)
With QUIC, clients can skip the handshake process when reconnecting to servers they have previously communicated with. This zero round-trip time (0-RTT) feature allows Node.js applications to reduce latency even further for repeat users, making interactions smoother and faster.
Improved Security
QUIC is encrypted by default, which means HTTP/3 provides more comprehensive security compared to HTTP/2.
To leverage HTTP/3 in Node.js Applications, ensure your server is configured to support HTTP/3. Many modern web servers, such as NGINX and Apache have also started offering support for HTTP/3.
Caching is a common technique to improve app performance. It’s done both on the client and server side. Client-side caching is the temporary storing of content such as HTML pages, CSS stylesheets, JS scripts, and multimedia content.
Client caches help limit data costs by keeping commonly referenced data locally on the browser or a content delivery network (CDN). An example of client caching is when the browser keeps frequently used data locally or data stored on a CDN.
The idea is that when a user visits a site and then returns to it, the site should not have to redownload all the resources again.
HTTP makes this possible via cache headers. Cache headers come in two forms:
Expires
— specifies the date upon which the resource must be requested againCache-Control: max-age
— specifies for how many seconds the resource is validUnless the resource has a cache header, the browser can only re-request the resource after the cache expiry date has passed. This approach has its drawbacks. For instance, what happens when a resource changes?
Somehow the cache has to be broken. You can solve this via the cache busting approach by adding a version number to the resource URL. When the URL changes, the resource is redownloaded. This is easy to do with Node.js tooling such as webpack.
Even if we enable client-side caching, the app server will still need to render data for each different user accessing the app, so there needs to be an implementation of caching on the server-side.
In Node.js, you can use Redis to store temporary data, known as object caching. In most cases, you can combine client- and server-side caching to optimize performance.
Optimization is a major factor in improving node.js performance because it simplifies system processes and boosts overall app efficiency. You might be wondering what can be optimized in a Node.js application?
Start by looking at how data is handled. Node.js programs can be slow due to a CPU/IO-bound operation, such as a database query or slow API call.
For most Node.js applications, data fetching is done via an API request, and a response is returned. How do you optimize that? One common method is pagination — i.e., separating responses into batches of content that can be browsed via selective response requests. You can use pagination to optimize the response while at the same time maintaining the greater amount of data that is passed to the user client.
Filtering is another effective approach — specifically, enabling the restriction of results by the criteria of the requester itself. Not only does this reduce the overall number of calls that are made and the results that are shown, but it also enables users to very precisely decide whether resources are provided based on their requirements. These two concepts are common in REST API design.
Underfetching and overfetching relate to how data is fetched. The former provides more data than is appropriate or useful to the client, and the latter does not respond with adequate data, often requiring a separate call to another endpoint to complete the data collection.
These two can occur from the client side and can be a result of poor app scaling. GraphQL is useful against this kind of problem because the server doesn’t have to guess what it needs; the client defines their request and gets exactly what they expected.
Another thing you could consider is optimizing your database queries to improve performance. A good way to improve optimizing your database queries is by creating indexes for frequently accessed queries. They work similarly to an index in a book, allowing the database engine to locate data without scanning the entire table.
For example, in an e-commerce platform, frequent queries might search for products by category or price range. Creating indexes on the category_id
, and price
columns can drastically reduce query times. A major drawback of this strategy is an increase in storage usage, so you have to use it sparingly.
Building performant applications that can handle a large number of incoming connections is a common challenge. A common solution is to distribute the traffic to balance the connections.
This approach is known as load balancing. Fortunately, Node.js allows you to duplicate an application instance to handle more connections. This can be done on a single multicore server or through multiple servers.
To scale the Node.js app on a multicore server, you can use the introduced cluster module, which spawns new processes called workers (one for each CPU core) that all run simultaneously and connect to a single master process, allowing the processes to share the same server port.
In that way, it behaves like one big, multithreaded Node.js server. You can use the cluster module to enable load balancing and distribute incoming connections according to a round-robin strategy across all the workers over an environment’s multiple CPU cores.
Another approach is to use the PM2 process manager to keep applications alive forever. This helps to avoid downtime by reloading the app whenever there’s a code change or error. PM2 comes with a cluster feature that enables you to run multiple processes across all cores without worrying about any code changes to implement the native cluster module.
The single-cluster setup has its drawbacks, and we need to prepare ourselves to switch from single-server architecture to a multiserver one with load balancing using reverse proxying. NGINX supports load balancing across multiple Node.js servers and various load balancing methods, including:
The reverse proxy feature protects the Node.js server from direct exposure to internet traffic and gives you a great deal of flexibility when using multiple application servers.
Most web apps need to keep the state to give users a personalized experience. If users can sign in to your site, you need to hold sessions for them.
When implementing stateful authentication, you would typically generate a random session identifier to store the session details on the server. To scale a stateful solution to a load-balanced application across multiple servers, you can use a central storage solution such as Redis to store session data or the IP hash method (in load balancing) to ensure that the user always reaches the same web server.
Such a stateful approach has its drawbacks. For example, limiting users to a specific server can lead to issues when that server needs some sort of maintenance.
Stateless authentication with JWT is another scalable approach — arguably, a better one. The advantage is that data is always available, regardless of which machine is serving a user.
A typical JWT implementation involves generating a token when a user logs in. This token is a base64 encoding of a JSON object containing the necessary user details. The token is sent back to the client and used to authenticate every API request.
The internet has traditionally been developed around the HTTP request/response model. WebSockets are an alternative to HTTP communications in web applications.
They provide a long-lived, bidirectional communication channel between the client and the server. If established, the channel is kept open, offering a very quick and persistent connection between the client and the server. Both parties can start sending data at any time with low latency and overhead.
HTTP is useful for occasional data sharing and client-driven communication that involves user interaction. With WebSockets, the server may send a message to the client without an explicit request from the client, allowing them to talk to each other simultaneously.
This is great for real-time and long-lived communications. ws is a popular library for Node.js that is used to implement a WebSockets server. On the front end, JavaScript is used to establish a connection to a WebSockets-enabled server and can then listen for events.
Holding a large number of connections open at the same time requires a high-competition architecture at a low cost of performance, and this is what WebSockets offers.
Monitoring in the context of Node.js performance optimization has to do with continuously observing certain metrics and logs of the app for health and performance while the app is being used.
Profiling on the other hand has to do with analyzing the app to identify some performance bottlenecks like speed of functions, memory usage, a garbage collection, etc.
Both of them can work together to improve the performance of your Node.js performance by identifying specific parts of your application where there are problems with slow function calls, high error rates, memory usage, and so on.
You can follow the steps below to improve performance using the monitoring and profiling strategy:
In this guide, we have gotten an idea of how to improve Node.js performance. We reviewed the effect of Node.js on frontend tools, how HTTP/3 enhances Node.js performance, specific caching solutions, and data handling methods you can use to enhance Node.js performance.
Then we discussed how to achieve load balancing on a Node.js app to manage more connections, the effect of stateful and stateless client-side authentication on scalability, and, finally, how WebSockets can provide a stable connection between client and server.
Now you’ve got everything you need to leverage Node.js performance capabilities and write efficient applications that your users will love.
Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third-party services are successful, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowHandle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
Design React Native UIs that look great on any device by using adaptive layouts, responsive scaling, and platform-specific tools.
Angular’s two-way data binding has evolved with signals, offering improved performance, simpler syntax, and better type inference.
2 Replies to "7 ways to improve Node.js performance at scale"
With respect, zero of these suggestions are how to “improve node.js performance” at scale; they are “some ways of sometimes improving REST APIs sometimes”. With that in mind, you raise some fair points, but stop short of explaining why they are useful
1. You don’t explain why bundling is useful. Is it to speed up the client? While useful, that’s not “improving node.js performance”. Is it to reduce load on the server by sending smaller files? Maybe.
2. SSL/TLS is slower and more work for your node.js server, this does not improve performance, but when used with HTTP/2, eh, fair. This left me wondering what performance improvement there was, ram/CPU/perf; a graph or some stats would be nice to back up your claim?
3. Caching is fair, but once again this is a pretty niche situation where you’re using node.js to serve static files – the proper optimization is to use a dedicated nginx server to host static files, and node.js to handle custom logic
4. Ultimately your conclusions make sense (consider pagination/filters/graphql), but half of the sentences don’t make sense – you throw in the word “optimize” into every sentence like you’re trying to do SEO
5. Once again this is very specifically how to scale a web server, NOT a node.js program or increase performance. There are so many things you should first consider before just “throwing more servers on it”, but this section is maybe useful
6. This really has nothing to do with performance. You clearly know your stuff on it, so maybe write a separate post going into details here, but this is nothing to do with performance, scaling or optimizations
7. Websockets may improve performance, but only typically in niche situations, and in most cases solve a different problem. If your client is making a few, small requests very infrequently, there’s no point in keeping an active TCP socket open and will instead waste resources on the server
Though you clearly know some stuff, and you raise a lot of solutions, just not to any of the problems you list. You also dive too deep into topics then leave them hanging. I’d recommend you pick a topic, e.g. caching, or advantages of websockets, or any of them really, and write a full post explaining it. Finally, the title is super misleading – node.js is so much more than building rest APIs for websites, and increasing performance is often none of the things you have listed – you haven’t even defined what sort of performance: speed/ram/CPU/overall cost, etc., which are you trying to solve?
Your words make sense. Thanks for this.
Question could be, API has some requests and resources are used fully.
How should one optimize the server, user has no more money to buy anything else?