Performance Best Practices

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. Everything was checked against the official documentation and the package repositories on 2026-10-29. The official performance and reliability page lists a short set of recommendations; this page follows them point by point and adds what they leave implicit: how to measure before changing anything, what each technique costs, and how the pieces combine in a production deployment of the Bookshelf API that runs through the whole Express.js section. The examples use ESM and the node: prefix for built-in modules, and contain placeholders only, never real secrets. The event-loop background is in Don’t Block the Event Loop and Profiling and Performance; process-level scaling is covered framework-free in Cluster and Scaling and Worker Threads.

The official recommendations at a glance

The official page splits its advice into two groups: things to do in the code and things to do in the environment. Express itself adds very little overhead; most performance problems are caused by blocking the single JavaScript thread, by sending more bytes than necessary, by recomputing what could be cached, or by running one process on a machine with many cores.

Area Recommendation Where it is covered below

Code

Use gzip (or Brotli) compression

Compression

Code

Do not use synchronous functions

Avoiding synchronous work

Code

Do logging correctly

Logging

Code

Handle exceptions properly

Handling exceptions

Code

Offload CPU-bound work

CPU-bound work and worker threads

Environment

Set NODE_ENV to production

NODE_ENV and production mode

Environment

Ensure your app automatically restarts

Automatic restarts: process managers and init systems

Environment

Run your app in a cluster

Running in a cluster

Environment

Cache request results

Caching

Environment

Use a load balancer and a reverse proxy

Load balancers, reverse proxies and sticky sessions

Method

Measure with a load generator before and after

Measuring with autocannon

Measure first

Every technique on this page trades complexity for speed, so apply none of them without a baseline. A useful baseline has three numbers: requests per second at a fixed concurrency, latency percentiles (p50, p97.5 and p99, not the average), and event-loop delay or CPU usage while the test runs. Section Measuring with autocannon shows how to take them. Two rules keep the numbers honest: run the load generator on a different machine (or at least a different set of cores) than the server, and test the production configuration (NODE_ENV=production, real logging, a real database), because a benchmark of a toy app with logging disabled predicts nothing.

Compression

Compressing text responses (HTML, JSON, CSS, JavaScript, SVG) usually shrinks them by 70 percent or more and is the cheapest win for clients on slow networks. There are two places to do it.

At the reverse proxy (preferred)

The official guidance is to compress at the reverse proxy when you have one: Nginx, Caddy, HAProxy and the cloud load balancers do it with native code and keep the CPU cost out of the Node.js event loop. With Nginx, the relevant directives are gzip on, gzip_types, gzip_min_length and gzip_comp_level. Compressing in Node.js is still the right choice when there is no proxy in front, in development, or when the proxy is a plain TCP load balancer.

In Express with the compression middleware

The compression module is the official middleware. It negotiates with the Accept-Encoding request header, compresses with gzip or deflate (recent releases also support Brotli, so check the module’s page for the version you install), sets Content-Encoding and Vary: Accept-Encoding, and removes Content-Length because the final size is unknown. Mount it before the routes whose responses it should compress:

// compression.mjs
import express from 'express';
import compression from 'compression';

const app = express();

app.use(
  compression({
    threshold: 1024, // do not compress bodies smaller than 1 kB (the default)
    level: 6,        // zlib level 1 (fastest) to 9 (smallest); -1 is the zlib default
    filter(req, res) {
      // Let clients opt out, for example while debugging with curl.
      if (req.headers['x-no-compression']) return false;
      return compression.filter(req, res); // default: compress compressible MIME types
    },
  }),
);

app.get('/api/v1/books', (req, res) => {
  res.json(Array.from({ length: 500 }, (_, i) => ({ id: i + 1, title: `Book ${i + 1}` })));
});

app.listen(3000);

The options that matter in practice:

Option Effect

threshold

Minimum body size in bytes before compression is attempted. Tiny bodies cost more CPU than they save bytes.

level

zlib compression level. Higher levels cost much more CPU for small gains; 4 to 6 is the usual sweet spot for dynamic content.

filter

Function deciding per response. The default only compresses types recognised as compressible by the compressible package and honours Cache-Control: no-transform.

chunkSize, memLevel, windowBits, strategy

Passed to zlib. Rarely worth changing.

Three pitfalls come up repeatedly. First, do not compress already-compressed formats (JPEG, PNG, WebP, MP4, ZIP); the default filter skips them. Second, compressing responses that mix secrets and attacker-controlled input can enable BREACH-style attacks; the usual mitigations are to avoid reflecting user input next to secrets or to disable compression for those endpoints. Third, server-sent events need an explicit flush, because the compressor buffers; call res.flush() after each event or exclude the route through filter. Static assets are better served pre-compressed (.gz or .br files produced at build time) by the proxy or a CDN than compressed on every request; see Serving Static Files.

Avoiding synchronous work

Node.js runs all JavaScript on one thread. A synchronous call that takes 50 ms stops every other request for 50 ms, so a server that handles 20 such requests per second is saturated, whatever its hardware. The official page puts it bluntly: do not use synchronous functions in production request paths. The usual offenders:

  • the Sync variants of file-system calls (readFileSync, writeFileSync, statSync, existsSync) and of node:child_process (execSync, spawnSync);

  • CPU-heavy built-ins used synchronously: crypto.pbkdf2Sync, crypto.scryptSync, zlib.gzipSync;

  • JSON.parse and JSON.stringify on very large documents, and regular expressions with catastrophic backtracking;

  • template compilation and require or import of modules inside handlers.

Startup code is the one legitimate place for synchronous calls: reading a configuration file or a template before app.listen() runs happens once. To find the rest, start the process with --trace-sync-io, which prints a warning and a stack trace each time a synchronous API is called after the first turn of the event loop:

node --trace-sync-io server.mjs

The flag is a diagnostic: it is noisy, so use it in staging, not in production. Pair it with the event-loop delay histogram from node:perf_hooks, which gives the symptom (the loop is late) while the flag gives the cause:

// event-loop-lag.mjs
import { monitorEventLoopDelay } from 'node:perf_hooks';

const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();

setInterval(() => {
  const p99 = histogram.percentile(99) / 1e6; // nanoseconds to milliseconds
  if (p99 > 100) console.warn(`event loop p99 delay ${p99.toFixed(1)} ms`);
  histogram.reset();
}, 10_000).unref();

The fix for a blocking call is almost always its asynchronous counterpart (node:fs/promises, crypto.pbkdf2 with a promise wrapper), and, for pure computation, a worker thread (see CPU-bound work and worker threads). Asynchronous password hashing runs on the libuv thread pool, whose size defaults to four threads and can be changed with the UV_THREADPOOL_SIZE environment variable; a burst of logins can exhaust it and delay file-system calls that share it.

Logging

Logging is a performance feature in both directions. The official advice is to use a logging library rather than console.log in production, because console.log and console.error are synchronous when stdout is a file or a terminal (and asynchronous only for pipes and sockets on POSIX systems), so heavy logging directly slows requests. Two library families are recommended:

  • for debugging, the debug module with namespaces switched by the DEBUG environment variable, which is how Express itself logs (see Debugging and Logging);

  • for application logs, a fast structured logger such as pino (or winston), writing JSON to stdout so that the platform collects it.

pino is designed to keep logging off the hot path: it serialises minimal JSON and leaves rotation, shipping and pretty-printing to other processes. pino-http adds a request logger with a per-request child logger and a request id:

// logging.mjs
import { randomUUID } from 'node:crypto';
import express from 'express';
import pino from 'pino';
import pinoHttp from 'pino-http';

const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  redact: ['req.headers.authorization', 'req.headers.cookie'], // never log credentials
});

const app = express();

app.use(
  pinoHttp({
    logger,
    genReqId: (req) => req.headers['x-request-id'] ?? randomUUID(),
    // Health probes would drown the log; skip them.
    autoLogging: { ignore: (req) => req.url === '/healthz' },
  }),
);

app.get('/api/v1/books/:id', (req, res) => {
  req.log.info({ bookId: req.params.id }, 'loading book'); // child logger carries the request id
  res.json({ id: req.params.id, title: 'Placeholder title' });
});

app.listen(3000, () => logger.info('Bookshelf API listening on port 3000'));

Keep the log level at info or above in production, never log request bodies by default, redact credentials, and write to stdout instead of to files from inside the process: log rotation and shipping belong to the platform (systemd journal, Docker, Kubernetes, a log agent). In development, pipe the output through pino-pretty rather than enabling pretty printing in the application.

Handling exceptions

An uncaught exception in an Express application is not just a failed request: in Node.js it terminates the process and every in-flight request with it. The official page lists the tools for keeping that from happening and the one thing not to do.

  • Use try/catch for synchronous code and async functions for asynchronous code. In Express 5 a rejected promise returned by a handler or middleware is forwarded to the error handlers automatically; in Express 4 you must call next(err) yourself or wrap the handler. Details are in Error Handling.

  • Do not use process.on('uncaughtException') to keep running. After an uncaught exception the process state is undefined, resources may be half-initialised, and the official page recommends letting the process die and be restarted by a supervisor (see Automatic restarts: process managers and init systems). A handler is acceptable only to log the error, flush logs and exit.

  • Handle unhandledRejection the same way: log, then exit with a non-zero code. Since Node.js 15 an unhandled rejection already terminates the process by default.

// last-resort.mjs
import pino from 'pino';

const logger = pino();

function fatal(error, origin) {
  logger.fatal({ err: error, origin }, 'fatal error, exiting');
  // pino writes synchronously to stdout by default, so the line is flushed before exit.
  process.exit(1);
}

process.on('uncaughtException', (error, origin) => fatal(error, origin));
process.on('unhandledRejection', (reason) => fatal(reason, 'unhandledRejection'));

For a clean stop that finishes in-flight requests before exiting, see Health Checks and Graceful Shutdown.

CPU-bound work and worker threads

Anything that is computation rather than waiting (image resizing, PDF generation, large JSON transforms, hashing a big file, report aggregation) blocks the loop for as long as it runs, and making it asynchronous in appearance does not change that. Three options exist, in order of preference:

  1. Move the work out of the request: enqueue a job and answer 202 Accepted with a status URL. A queue consumer in a separate process then does the work (see Background Jobs, Scheduling and Queues).

  2. Run it in a worker thread from node:worker_threads, sharing the process but not the event loop.

  3. Run it in a child process or another service when isolation or a different runtime is needed.

Creating a worker per request is expensive (each worker is a new V8 isolate), so use a pool. piscina is the widely used one: it keeps a fixed number of workers, queues tasks and returns promises.

// workers/summarise.mjs  (runs inside a worker thread)
export default function summarise({ text }) {
  // Deliberately CPU-heavy: word frequencies over a large text.
  const counts = new Map();
  for (const word of text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []) {
    counts.set(word, (counts.get(word) ?? 0) + 1);
  }
  return [...counts].sort((a, b) => b[1] - a[1]).slice(0, 10);
}
// pool.mjs
import { availableParallelism } from 'node:os';
import express from 'express';
import Piscina from 'piscina';

const pool = new Piscina({
  filename: new URL('./workers/summarise.mjs', import.meta.url).href,
  maxThreads: Math.max(1, availableParallelism() - 1), // leave a core for the event loop
  maxQueue: 100, // reject instead of queueing without bound
});

const app = express();
app.use(express.json({ limit: '1mb' }));

app.post('/api/v1/books/summary', async (req, res) => {
  if (pool.queueSize >= 100) {
    return res.status(503).set('Retry-After', '5').json({ error: 'Busy, retry later' });
  }
  const top = await pool.run({ text: String(req.body.text ?? '') }); // rejection reaches the error handler (Express 5)
  res.json({ top });
});

app.listen(3000);

Workers pay a serialisation cost for the data they receive and return (structured clone, or zero-copy for transferred ArrayBuffer objects), so they help when the computation is much more expensive than the copy. They do not help for I/O-bound work, which the event loop already handles concurrently.

NODE_ENV and production mode

Set the environment variable NODE_ENV to production in every deployed environment. Express itself, and many libraries, branch on it. The official page reports that doing so can make an application several times faster, and the documented effects in Express are:

  • view caching is enabled: compiled templates are cached instead of being recompiled on each res.render() (the view cache setting defaults to true in production);

  • error responses from the default handler omit the stack trace and show only the status message;

  • more CSS and file-system caching in supporting libraries, and less verbose behaviour in modules that check it (pino-pretty setups, ORMs, template engines).

Set it outside the code (the unit file, the container, the PaaS dashboard, the PM2 ecosystem file), never with an assignment inside the application, which runs too late for modules already loaded:

NODE_ENV=production node server.mjs

Install with npm ci --omit=dev for production images: devDependencies add startup time, image size and attack surface. NODE_ENV should only ever be development, test or production; inventing values such as staging makes libraries silently fall back to their development behaviour. Configuration that differs between staging and production belongs in separate variables (see Project Structure and Configuration).

Automatic restarts: process managers and init systems

A crashed process must come back without a human. The official page names two layers.

  • A process manager supervises the application: restarts it on crash, can run several copies, collects logs and offers zero-downtime reloads. PM2 is the best-known Node.js option; forever and nodemon are older and nodemon is a development tool, not a production supervisor.

  • An init system (systemd on most Linux distributions, or an orchestrator such as Kubernetes or ECS) starts the application at boot and restarts it. If you use an orchestrator or containers, it already is your process manager; do not run PM2 inside a container unless you need its cluster mode, because two supervisors give conflicting restart decisions.

A minimal systemd unit for the Bookshelf API:

# /etc/systemd/system/bookshelf.service
[Unit]
Description=Bookshelf API
After=network.target

[Service]
Type=simple
User=bookshelf
WorkingDirectory=/opt/bookshelf
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/etc/bookshelf/bookshelf.env
ExecStart=/usr/bin/node server.mjs
Restart=always
RestartSec=2
# Give the application time to finish in-flight requests on SIGTERM.
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target

The EnvironmentFile holds secrets and is readable only by root; the repository contains placeholders only. Pair Restart=always with a crash-loop limit (StartLimitBurst) so a permanently broken release does not restart forever without anyone noticing.

Running in a cluster

One Node.js process uses one core for JavaScript. On a four-core machine a single process leaves three cores idle. Clustering runs several identical processes on the same machine, all listening on the same port, so that connections spread across cores; it also keeps the service up while one worker restarts. Express 4 and 5 are unaffected: the technique is a property of the process model.

node:cluster

The built-in node:cluster module forks workers from a primary process; the operating system or the primary distributes incoming connections (round-robin on Linux and macOS by default):

// cluster.mjs
import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';
import process from 'node:process';

if (cluster.isPrimary) {
  const workers = Number(process.env.WEB_CONCURRENCY ?? availableParallelism());
  console.log(`primary ${process.pid} starting ${workers} workers`);
  for (let i = 0; i < workers; i++) cluster.fork();

  cluster.on('exit', (worker, code, signal) => {
    console.warn(`worker ${worker.process.pid} died (${signal ?? code}), forking a replacement`);
    cluster.fork();
  });
} else {
  const { default: express } = await import('express');
  const app = express();
  app.get('/api/v1/books', (req, res) => res.json({ worker: process.pid, books: [] }));
  app.listen(3000);
}

Workers do not share memory. Anything kept in a JavaScript variable (an in-memory cache, a Map of sessions, a rate-limit counter, a WebSocket room) exists once per worker, so state must move to Redis or a database. A restart loop without a delay can also spin the CPU; add a back-off if workers die immediately.

PM2 cluster mode

PM2 wraps node:cluster so the application code needs no changes, and adds log handling and graceful reloads. The official page shows the quick form pm2 start app.js -i max; a configuration file is better for repeatability:

// ecosystem.config.cjs
module.exports = {
  apps: [
    {
      name: 'bookshelf-api',
      script: './server.mjs',
      exec_mode: 'cluster',
      instances: 'max', // one worker per CPU core
      max_memory_restart: '400M', // restart a worker that leaks past this size
      kill_timeout: 10000, // ms between SIGINT and SIGKILL for graceful shutdown
      env_production: {
        NODE_ENV: 'production',
        PORT: 3000,
      },
    },
  ],
};
pm2 start ecosystem.config.cjs --env production
pm2 reload bookshelf-api   # zero-downtime restart, one worker at a time
pm2 save && pm2 startup    # restore the process list at boot

The PM2 configuration file uses CommonJS (.cjs) so that it loads regardless of the type field in package.json. In containers and Kubernetes the common alternative is one process per container and horizontal scaling by the orchestrator, which gives the same parallelism with simpler failure handling; use node:cluster or PM2 when you run on a bare virtual machine.

Caching

The fastest request is the one the application never computes. The official page recommends caching request results so that the application does not repeat the same work, and there are several layers, from the client inward:

Layer What it caches Typical tool

Browser and CDN

Complete responses, driven by Cache-Control, ETag and Last-Modified

HTTP headers set by Express, a CDN

Reverse proxy

Complete responses shared by all users

Nginx proxy_cache, Varnish, Caddy cache modules

Application

Results of expensive computations or queries, keyed by input

In-process Map or LRU cache, Redis

Database

Query plans and hot pages

Managed by the database

HTTP-level caching, including the ETag that Express generates by default, conditional requests and Cache-Control directives, is the subject of Content Negotiation and HTTP Caching. For the application layer, a small helper that caches an expensive read in Redis, with a time-to-live and an explicit invalidation on writes, covers most needs:

// cache.mjs
import express from 'express';
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL ?? 'redis://localhost:6379' });
redis.on('error', (error) => console.error('redis error', error.message));
await redis.connect();

async function cached(key, ttlSeconds, load) {
  const hit = await redis.get(key);
  if (hit !== null) return JSON.parse(hit);
  const value = await load();
  await redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
  return value;
}

const books = new Map([['1', { id: '1', title: 'Placeholder title' }]]); // stands in for the database

const app = express();
app.use(express.json());

app.get('/api/v1/books/:id', async (req, res) => {
  const book = await cached(`book:${req.params.id}`, 60, async () => books.get(req.params.id) ?? null);
  if (!book) return res.status(404).json({ error: 'Not found' });
  res.set('Cache-Control', 'public, max-age=30');
  res.json(book);
});

app.put('/api/v1/books/:id', async (req, res) => {
  books.set(req.params.id, { id: req.params.id, ...req.body });
  await redis.del(`book:${req.params.id}`); // invalidate on write
  res.status(204).end();
});

app.listen(3000);

Four rules keep a cache from becoming a source of bugs. Key on everything the response depends on (path, query, and for per-user data the user identity; never cache Set-Cookie or authenticated responses in a shared cache). Always set a time-to-live, so a missed invalidation heals itself. Prevent stampedes on a popular key expiring (a short lock, or serving stale data while one request refreshes). And treat the cache as optional: if Redis is down, fall back to the database rather than failing the request.

At the proxy, a public read endpoint can be cached by Nginx without any application code, provided the application sends correct Cache-Control headers; Varnish does the same with VCL rules. Because the proxy is not part of the Express process, it also survives application restarts.

Load balancers, reverse proxies and sticky sessions

Beyond a single machine, a load balancer spreads traffic across several instances of the application and removes the ones that fail health checks. A reverse proxy (Nginx, Caddy, HAProxy, a cloud edge) sits in front of one or more instances and takes over work Node.js is poor at: TLS termination, compression, static file serving, request buffering for slow clients, rate limiting at the edge and caching. The official page recommends both, and the two are usually the same product.

Two consequences for the application:

  • Behind a proxy, Express must be told to trust it, or req.ip, req.protocol and req.secure describe the proxy instead of the client. Configure the trust proxy setting precisely, as explained in Behind Proxies.

  • Instances must be stateless. Sessions, uploads, caches and rate-limit counters held in memory break as soon as two requests of one user reach different instances. Store them in Redis or a database. When that is not possible, sticky sessions (session affinity, by cookie or by client address) pin a user to one instance; they work but defeat even load distribution, complicate rolling deployments and lose the session when the instance dies. WebSocket libraries that use long-polling fallbacks, such as Socket.IO, need affinity or a shared adapter (see Real-time: WebSockets and SSE).

An Nginx configuration that combines proxying, compression and caching for the Bookshelf API:

# /etc/nginx/conf.d/bookshelf.conf
proxy_cache_path /var/cache/nginx/bookshelf levels=1:2 keys_zone=bookshelf:10m max_size=256m inactive=10m;

upstream bookshelf_api {
    least_conn;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    keepalive 32;
}

server {
    listen 80;
    server_name api.example.test;

    gzip on;
    gzip_types application/json text/css application/javascript image/svg+xml;
    gzip_min_length 1024;

    location /api/ {
        proxy_pass http://bookshelf_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;

        proxy_cache bookshelf;
        proxy_cache_valid 200 30s;
        proxy_cache_methods GET HEAD;
        add_header X-Cache-Status $upstream_cache_status;
    }
}

The keepalive directive reuses upstream connections, which removes a TCP handshake from every proxied request.

Measuring with autocannon

autocannon is a fast HTTP/1.1 benchmarking tool written in Node.js, usable from the command line or as a library. A typical run uses a number of connections, a duration, and optionally pipelining:

npx autocannon --connections 100 --duration 20 --pipelining 1 http://localhost:3000/api/v1/books

The report shows latency percentiles, requests per second, throughput and the count of non-2xx responses, which should be zero in a valid run. The same measurement can be scripted and compared before and after a change:

// bench.mjs
import autocannon from 'autocannon';

const result = await autocannon({
  url: process.env.TARGET ?? 'http://localhost:3000/api/v1/books',
  connections: 100,
  duration: 20,
  headers: { 'accept-encoding': 'gzip' },
});

console.log(`requests/s (avg): ${result.requests.average}`);
console.log(`latency p99: ${result.latency.p99} ms`);
console.log(`non-2xx responses: ${result.non2xx}`);
if (result.non2xx > 0) process.exitCode = 1;

A disciplined experiment changes one thing at a time, repeats each run at least three times, warms the server before measuring, and records the Node.js version, Express version, core count and exact command. When a change does not move the numbers, revert it. For finding where the time goes once a regression is visible, use the profilers described in Profiling and Performance.

Production checklist

Item Check

Environment

NODE_ENV=production, npm ci --omit=dev, a current LTS Node.js release

Compression

Done at the proxy, or compression mounted early with a sensible threshold

Event loop

No Sync calls in request paths, CPU work in a worker pool or a queue, event-loop delay monitored

Logging

Structured JSON to stdout with pino, credentials redacted, level info

Failure

Supervisor with Restart=always or equivalent, fatal handlers that log and exit, graceful shutdown on SIGTERM

Scale

One process per core (PM2, node:cluster) or one per container, stateless instances

Caching

Correct Cache-Control and ETag, proxy cache for public reads, Redis with TTL for expensive results

Proxy

trust proxy configured, keep-alive to the upstream, health checks on the load balancer

Measurement

An autocannon baseline stored with the Node.js and Express versions

Where the books are outdated

The reference books were written for Express 3 and 4 on early Node.js releases. The performance advice they touch has changed in these ways:

Older practice Today

Using forever or nodemon to keep a production server alive

Use systemd, a container orchestrator or PM2; nodemon is for development only.

Running a single Node.js process, or a hand-written cluster

Use node:cluster (stable and unchanged in shape), PM2 cluster mode or one container per core with an orchestrator; os.cpus().length is better replaced by os.availableParallelism().

Logging with console.log or Express’s bundled logger middleware (Connect 2)

Use morgan for access logs or pino-http for structured logs; the bundled Connect middleware was removed in Express 4.

Compressing with the bundled express.compress()

Removed in Express 4; install the compression package or compress at the proxy.

Keeping the process alive with an uncaughtException handler

Let the process crash and restart it; log and exit in the handler.

Offloading CPU work to a child process by default

Worker threads (stable since Node.js 12) and pools such as piscina are the usual choice.

Hosting on Heroku free dynos or similar PaaS tiers, with in-memory sessions

Free tiers changed or disappeared; design instances to be stateless and keep sessions in Redis or a database.

Blocking template rendering and synchronous file reads in handlers

Use asynchronous APIs; Express 5 always renders asynchronously, and --trace-sync-io finds the remainder.