Hot reloading is a staple of Node.js development: when a file changes, your application restarts, re-running all setup logic. For small projects, this is barely noticeable. But for large monoliths, especially those with heavy startup routines, the wait can be frustrating.
This was exactly my situation. My application used webpack-dev-middleware
to compile a massive monolithic project with thousands of modules. Each restart triggered a full Webpack build, which, thanks to legacy loaders, could take up to 70 seconds. On top of that, one server module fetched a 60MB JSON configuration from an external source, and parsing this data took another 90 seconds. Every minor change meant waiting a minute and a half before I could see the results—completely killing my development feedback loop.
Why Not Just Optimize?
I considered several approaches:
Load configuration on-demand:
Not feasible. The configuration was deeply coupled and used everywhere, so lazy loading wasn’t practical.Decouple configuration-dependent modules:
This would only help for modules that didn’t need the config, but most did.Optimize load times:
We were already transitioning to Vite, but the bottleneck was the unavoidableJSON.parse
on a huge file.Cache heavy operations between reloads:
This seemed the most promising: minimal effort, maximum impact.
But how do you cache variables between restarts?
Webpack HMR and why it did not work for me
Webpack implements its own module system, enabling Hot Module Replacement (HMR) in both browser and Node.js environments. In development, you can use module.hot
to keep state between reloads:
const expensiveVariable = module.hot?.data?.expensiveVariable ?? createExpensiveVariable();
if (module.hot) {
module.hot.dispose((data) => {
data. expensiveVariable = expensiveVariable;
});
}
However, this requires compiling your app with Webpack, which is slow and complex for large backends. I deemed this completely unnecessary for my plain JavaScript backend. Other tools like Babel or SWC don’t provide HMR for Node.js.
The CommonJS Trick: Keeping Variables Between Restarts
Since my app was written in CommonJS, I could leverage Node’s require.cache
API. This cache tracks all loaded modules. By clearing the cache for everything except a special “keeper” module, I could persist variables across reloads. This is not possible with ES Modules, as they don’t expose a public cache API.
The Hot-Reload Entrypoint
To make this work, I needed a new entrypoint that:
- Watches source files for changes
- Clears the
require
cache for changed files - Restarts the application in the same process
Killing and respawning a child process wouldn’t work, since each process has its own memory and cache. Instead, the entrypoint must control the server and be able to close and restart it in-place.
Here’s a simple implementation:
const fs = require('fs');
const http = require('http');
const { createRequire } = require('module');
const chokidar = require('chokidar');
const requireApp = createRequire(__filename);
let server = null;
function startServer() {
if (server) server.close();
const app = requireApp('./app.js');
server = http.createServer(app).listen(3000);
}
chokidar.watch('./src').on('change', (file) => {
Object.keys(require.cache).forEach((key) => {
if (key.includes('/src/')) delete require.cache[key];
});
startServer();
});
startServer();
You could optimize further by only clearing changed modules and their dependents, but this is more complex and not always faster.
The Variable Cache Module
To persist variables, I created a special module that is never cleared from the cache. It simply exports keep
and kept
functions:
// keeper.js
const store = new Map();
function keep(name, value) {
store.set(name, value);
return value;
}
function kept(name, defaultValue) {
return store.has(name) ? store.get(name) : defaultValue;
}
module.exports = { keep, kept };
In the app, it is used like this:
const { keep, kept } = require('./keeper');
// On startup, restore or create the expensive resource
let expensiveResource = kept('expensiveResource', null);
if (!expensiveResource) {
expensiveResource = createExpensiveResource();
keep('expensiveResource', expensiveResource);
}
Now, when the app reloads, the resource is kept alive!
Forcibly Closing Connections
One last challenge: some middlewares (like webpack-dev-middleware
or webpack-hot-middleware
) keep connections open, preventing the server from closing. To fix this, the entrypoint must forcibly close all open sockets:
const sockets = new Set();
server.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});
function closeServer() {
for (const socket of sockets) {
socket.destroy();
}
server.close();
}
Probably those middlewares should handle
close
calls themselves and destroy active connections, but they did not.
The Result
With this setup, I can now restart my server almost instantly after a file change, instead of waiting 90 seconds or more. The feedback loop is tight, and development isn't painful anymore.
Introducing: hot-keeper
I’ve packaged this approach into a reusable CLI utility: hot-keeper
.
Install it from npm:
pnpm add hot-keeper
Use it as your entrypoint:
pnpm hot-keeper app.js
And in your app, use the cache helpers:
const { keep, kept } = require('hot-keeper');
let expensive = kept('expensive', null);
if (!expensive) {
expensive = createExpensive();
keep('expensive', expensive);
}
For further configuration and more examples, see the README.
Now you can enjoy fast reloads regardless of setup routines in your Node.js CommonJS projects!
Top comments (0)