Node

Node.js on Ubuntu: Moving Off 16.04 and Tuning Your Server for Performance

ubuntu
On this page
  1. Why Ubuntu 16.04 is a problem for Node.js
  2. Upgrading: in place or a fresh server?
  3. Migration checklist
  4. Installing Node.js the right way
  5. Keep the app running: PM2 or systemd
  6. Raise the open file limit
  7. Memory: heap size and swap
  8. Put Nginx in front
  9. Measure before you tune
  10. Kernel settings: usually not needed
  11. Keep the new server healthy
  12. Frequently asked questions
  13. Oh hi there 👋It’s nice to meet you.
  14. Sign up to receive awesome content in your inbox.

Many guides about tuning Node.js on Ubuntu 16.04 are still online, and many small servers still run it. But in 2026, the best performance advice for 16.04 is to leave it. This article explains why, shows a safe way to move to a current Ubuntu LTS release, and then covers the tuning that genuinely makes Node.js faster and more reliable on a modern Ubuntu server.

Why Ubuntu 16.04 is a problem for Node.js

Ubuntu 16.04 “Xenial Xerus” was released in April 2016. Its standard support ended in April 2021, after which only paid extended security maintenance was available. For Node.js developers, the bigger issue is technical: official Node.js binaries from version 18 onward need a newer version of the system C library (glibc) than Ubuntu 16.04 ships. Installing a current Node.js LTS release on 16.04 either fails or produces errors such as:

node: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.28' not found (required by node)

That leaves 16.04 stuck on Node.js versions that no longer receive security fixes. Native npm packages, such as image processing libraries and database drivers with compiled parts, increasingly fail to install for the same reason. And outdated system packages, including OpenSSL, mean weaker TLS and known vulnerabilities on an internet-facing server.

No amount of tuning makes up for an unsupported runtime. The first and most effective optimization is a supported operating system.

Upgrading: in place or a fresh server?

There are two ways to get to a current release.

In-place upgrade. Ubuntu upgrades one LTS release at a time: 16.04 to 18.04, then 20.04, then 22.04, then 24.04, each with sudo do-release-upgrade and a reboot. It works, but four major upgrades on a production server is risky: configuration files change, services break between steps, and a failure halfway can leave the server unbootable.

Fresh server and migration (recommended). Create a new server with the latest Ubuntu LTS, set it up cleanly, move your app and data, test it, and switch traffic over. The old server keeps running until you are sure, so you can always go back. It is also a chance to write down every setup step so the next migration is easy.

Migration checklist

  1. Inventory the old server: running services (systemctl list-units --type=service --state=running), cron jobs (crontab -l for each user), Nginx sites, environment variables, open ports, SSL certificates and where data lives.
  2. Back up everything: databases with mongodump or mysqldump, uploaded files, .env files and Nginx configuration.
  3. Set up the new server: create a normal user, SSH keys, firewall and automatic security updates, as described in the Node.js production checklist.
  4. Install current Node.js LTS (next section) and your process manager.
  5. Deploy the app from Git, not by copying files, so the new server matches your repository exactly. Run npm ci fresh; never copy node_modules between machines.
  6. Restore data and run your test checklist against the new server’s IP address.
  7. Lower DNS TTL a day before, then switch DNS to the new server. Keep the old one running for a week in case something was missed.

Installing Node.js the right way

Ubuntu’s own apt install nodejs package is often several versions behind. Two better options:

# Option 1: NodeSource packages for the system (good for servers)
curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh
less nodesource_setup.sh            # read scripts before running them as root
sudo -E bash nodesource_setup.sh
sudo apt install -y nodejs

# Option 2: a version manager per user (good for development and multiple versions)
# install nvm or fnm, then:
nvm install --lts
nvm alias default lts/*

Replace 22 with whichever version is the current active LTS when you read this. Check with node -v and npm -v.

Keep the app running: PM2 or systemd

PM2 is popular and adds clustering and log management. systemd is already built into Ubuntu and needs nothing extra. Both are good choices.

# PM2: one process per CPU core, restarted on crash and on reboot
npm install -g pm2
pm2 start server.js --name api -i max --max-memory-restart 600M
pm2 save
pm2 startup systemd
# systemd: /etc/systemd/system/api.service
[Unit]
Description=Node.js API
After=network.target

[Service]
User=app
WorkingDirectory=/home/app/api
Environment=NODE_ENV=production
EnvironmentFile=/home/app/api/.env
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=3
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

Enable it with sudo systemctl enable --now api and read logs with journalctl -u api -f. With systemd, run several instances on different ports behind Nginx if you want to use more than one CPU core.

Raise the open file limit

Every network connection uses a file descriptor. Ubuntu’s default per-process limit for services is often too low for a busy Node.js server, which then fails with EMFILE: too many open files. Set LimitNOFILE in the systemd unit as above, or for PM2 add the same line to PM2’s own systemd service. Check the limit a running process actually has:

cat /proc/$(pgrep -f "node server.js" | head -1)/limits | grep "open files"

Memory: heap size and swap

Node.js decides its default heap limit from the machine’s memory. On a small VPS, a memory-hungry app can be killed by the operating system’s out-of-memory killer without any Node.js error. Two settings help:

# Give Node.js an explicit heap limit below the server's RAM (in MB)
node --max-old-space-size=1536 server.js

# Add a 2 GB swap file as a safety net
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

Swap is a safety net, not extra speed: if your app uses swap constantly, it needs more RAM or less memory use. Check with free -h and look for real memory leaks using heap snapshots in Chrome DevTools.

Put Nginx in front

Nginx handles HTTPS, compression, static files and slow clients efficiently, freeing Node.js to run your application code. Turn on compression and keep connections to Node alive:

upstream api {
    server 127.0.0.1:5000;
    keepalive 32;
}

server {
    listen 443 ssl;
    server_name api.yourdomain.com;
    gzip on;
    gzip_types application/json text/css application/javascript;

    location / {
        proxy_pass http://api;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

The keepalive setting reuses connections between Nginx and Node instead of opening a new one for every request, which noticeably lowers latency under load.

Measure before you tune

Most real performance problems are in the application, not the operating system: slow database queries, missing indexes, large JSON responses or code that blocks the event loop. Find the actual bottleneck first:

  • htop shows whether CPU or memory is the limit.
  • pm2 monit shows per-process CPU, memory and restarts.
  • node --cpu-prof server.js writes a CPU profile you open in Chrome DevTools to see which functions take the time.
  • Slow query logs in MongoDB or MySQL reveal database problems, usually fixed with an index.
  • Load testing with a tool such as autocannon (npx autocannon -c 50 -d 20 http://localhost:5000/api/products) shows how many requests per second you handle before and after each change.

Event loop blocking is a common hidden cause of slow Node.js servers; see how Node.js handles thousands of requests on one thread for how to spot and fix it.

Kernel settings: usually not needed

Old tuning guides recommend many kernel changes, such as raising net.core.somaxconn or TCP buffer sizes. Modern Ubuntu defaults are sensible for typical web apps, so change these only when measurements show a specific problem, for example connection errors during traffic spikes at thousands of new connections per second. Changing kernel settings without measuring often does nothing, and occasionally makes things worse.

Keep the new server healthy

A fresh server only stays fast and safe with a little regular care. A short routine prevents the slow decay that left so many apps stuck on 16.04:

  • Automatic security updates: unattended-upgrades installs security patches daily without your attention.
  • Monthly check: run sudo apt update && sudo apt upgrade, review npm outdated for your app, and reboot if the kernel was updated.
  • Log rotation: make sure application and Nginx logs rotate, so the disk doesn’t fill up silently.
  • Disk and memory alerts: get notified before the disk is full or memory runs out.
  • Plan the next upgrade: note when your Ubuntu LTS and Node.js LTS versions reach end of support, and schedule the move well before.

Frequently asked questions

Can I still run Node.js 16 or older on Ubuntu 16.04?

Technically yes, but those Node.js versions are end-of-life and receive no security fixes. Running them on an internet-facing server is a real risk; upgrading the operating system is the safer path.

Which Ubuntu version should I use for Node.js?

The newest LTS release that your hosting provider offers. LTS releases receive five years of standard updates, which gives you a long, stable base.

Is Docker a way around the old operating system?

Partly. A container can run a newer Node.js image on an old host, but the host kernel and Docker itself still need to be supported and patched. Upgrading the host remains the right long-term fix.

PM2 or systemd: which is better?

Both are reliable. PM2 is easier for clustering and logs in Node-only setups; systemd needs no extra software and manages every kind of service the same way. Pick one and use it consistently.

logo

Oh hi there 👋
It’s nice to meet you.

Sign up to receive awesome content in your inbox.

We don’t spam! Read our privacy policy for more info.

Saurabh

Saurabh Pathak is an AI engineer and MERN stack developer, and the founder of mernstackdev.com. He builds open-source projects such as the Haat gateway and MCP safety tooling, and he writes hands-on tutorials on React, Node.js, MongoDB and AI agents. He cares most about code that works in production.