
Why Node.js 15 Matters
Node.js 15 is a current release line (not LTS) that introduces game-changing features for modern JavaScript development. While it's superseded by newer versions, understanding its innovations helps appreciate Node.js evolution.
Key Insight: This release marked a major shift with npm 7's workspace support, making monorepo development native to Node.js, and critical behavior changes around promise rejection handling that caught many developers off guard.
Released in October 2020, Node.js 15.0.0 brought npm 7, V8 JavaScript engine 8.6, experimental QUIC support, and several breaking changes that required careful migration planning. Let's dive into what made this release significant.
1. npm 7: Workspaces & Peer Dependencies
The biggest feature in Node.js 15 was the inclusion of npm 7, a complete rewrite with monorepo support and automatic peer dependency installation.
npm Workspaces: Native Monorepo Support
npm workspaces allow you to manage multiple packages within a single repository, similar to Yarn Workspaces or Lerna, but built directly into npm.
{
"name": "my-monorepo",
"version": "1.0.0",
"private": true,
"workspaces": [
"packages/*",
"apps/*"
],
"scripts": {
"test": "npm run test --workspaces",
"build": "npm run build --workspaces --if-present"
},
"devDependencies": {
"jest": "^27.0.0",
"typescript": "^4.5.0"
}
}
my-monorepo/
├── package.json
├── package-lock.json
├── node_modules/
├── packages/
│ ├── shared-utils/
│ │ ├── package.json
│ │ └── index.js
│ └── api-client/
│ ├── package.json
│ └── index.js
└── apps/
├── web-app/
│ ├── package.json
│ └── src/
└── mobile-app/
├── package.json
└── src/
# Install dependencies for all workspaces
npm install
# Run script in specific workspace
npm run build --workspace=packages/shared-utils
# Run script in all workspaces
npm run test --workspaces
# Add dependency to specific workspace
npm install lodash --workspace=packages/api-client
# Add dependency to root (available to all)
npm install -D jest
Automatic Peer Dependencies Installation
Breaking Change Alert
npm 7 now installs peer dependencies by default. This was a major change from npm 6 where peer dependencies were only warned about but not installed.
{
"name": "my-react-library",
"version": "1.0.0",
"peerDependencies": {
"react": "^17.0.0",
"react-dom": "^17.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
},
"devDependencies": {
"react": "^17.0.0",
"react-dom": "^17.0.0"
}
}
npm 6 Behavior
Peer dependencies were not installed, only warned about. Developers had to install them manually.
npm 7 Behavior
Peer dependencies are automatically installed unless they conflict with existing versions.
New package-lock.json Format
npm 7 introduced lockfile version 2, which is backward compatible with npm 6 but includes additional workspace and peer dependency metadata.
Lockfile Features
- Deterministic dependency trees
- Workspace symlink information
- Peer dependency resolution metadata
- Backward compatible with npm 6

2. V8 JavaScript Engine 8.6
Node.js 15 upgraded from V8 8.4 to V8 8.6, bringing several ES2021 features and performance improvements.
Promise.any() & AggregateError
Promise.any() resolves when any of the promises fulfill, or rejects with an AggregateError if all promises reject.
// Fastest API response wins
const fetchFromMultipleSources = async () => {
const apiUrls = [
'https://api1.example.com/data',
'https://api2.example.com/data',
'https://api3.example.com/data'
];
try {
// Returns the first successful response
const data = await Promise.any(
apiUrls.map(url => fetch(url).then(r => r.json()))
);
console.log('Fastest response:', data);
return data;
} catch (error) {
// AggregateError contains all rejection reasons
console.error('All APIs failed:', error.errors);
throw new Error('No API available');
}
};
// Use case: Redundant service calls
const getDataWithFallbacks = () => {
return Promise.any([
fetchFromPrimaryDB(), // Try primary first
fetchFromCacheServer(), // Fallback to cache
fetchFromBackupDB() // Last resort
]);
};
Promise.all()
Waits for all to fulfill, rejects on first rejection
Promise.race()
Settles with first promise to settle (fulfill or reject)
Promise.any()
Fulfills with first fulfillment, rejects if all reject
String.prototype.replaceAll()
// Old way: Using regex or split/join
const oldWay = 'foo bar foo baz foo'.replace(/foo/g, 'qux');
console.log(oldWay); // 'qux bar qux baz qux'
// New way: Native replaceAll()
const newWay = 'foo bar foo baz foo'.replaceAll('foo', 'qux');
console.log(newWay); // 'qux bar qux baz qux'
// Real-world example: Template rendering
const template = 'Hello {{name}}, welcome to {{app}}!';
const rendered = template
.replaceAll('{{name}}', 'Dillip')
.replaceAll('{{app}}', 'TechBytes');
console.log(rendered); // 'Hello Dillip, welcome to TechBytes!'
// Sanitizing user input
const sanitizeSQL = (query) => {
return query
.replaceAll("'", "''") // Escape single quotes
.replaceAll(';', '\\;'); // Escape semicolons
};
const userInput = "O'Reilly; DROP TABLE users;";
console.log(sanitizeSQL(userInput));
// "O''Reilly\\; DROP TABLE users\\;"
Logical Assignment Operators
New operators that combine logical operations with assignment: &&=
, ||=
, and ??=
.
// 1. AND Assignment (&&=)
// Only assigns if left side is truthy
let user = { name: 'Alice', age: 30 };
user.age &&= user.age + 1; // age is truthy, so increment
console.log(user.age); // 31
// Equivalent to:
if (user.age) {
user.age = user.age + 1;
}
// 2. OR Assignment (||=)
// Assigns if left side is falsy
let config = { timeout: 0 };
config.timeout ||= 5000; // 0 is falsy, assign default
console.log(config.timeout); // 5000
// Equivalent to:
config.timeout = config.timeout || 5000;
// 3. Nullish Coalescing Assignment (??=)
// Assigns only if left side is null or undefined
let options = { retry: 0, cache: null };
options.retry ??= 3; // 0 is not nullish, don't assign
options.cache ??= true; // null is nullish, assign
console.log(options.retry); // 0 (unchanged)
console.log(options.cache); // true
// Real-world use case: Default configuration
class APIClient {
constructor(options = {}) {
this.baseURL = options.baseURL;
this.baseURL ??= 'https://api.example.com';
this.timeout = options.timeout;
this.timeout ??= 5000;
this.retries = options.retries;
this.retries ??= 3;
}
}
// Usage
const api1 = new APIClient({ timeout: 0 });
console.log(api1.timeout); // 0 (explicit 0 is preserved)
const api2 = new APIClient({});
console.log(api2.timeout); // 5000 (default applied)
3. AbortController API (Stable)
AbortController moved from experimental to stable, enabling cancellation of async operations like HTTP requests and file operations.
// 1. Basic fetch cancellation
const controller = new AbortController();
const signal = controller.signal;
// Set timeout for request
setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch('https://api.example.com/slow', { signal });
const data = await response.json();
console.log(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request cancelled');
} else {
console.error('Request failed:', error);
}
}
// 2. Cancel on user action
const searchController = new AbortController();
document.getElementById('search').addEventListener('input', async (e) => {
// Cancel previous search
searchController.abort();
// Create new controller for this search
const newController = new AbortController();
try {
const results = await fetch(`/api/search?q=${e.target.value}`, {
signal: newController.signal
});
displayResults(await results.json());
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Search failed:', error);
}
}
});
// 3. Cancel multiple operations with same signal
const batchController = new AbortController();
const { signal } = batchController;
const operations = [
fetch('/api/users', { signal }),
fetch('/api/posts', { signal }),
fetch('/api/comments', { signal })
];
// Cancel all operations
setTimeout(() => batchController.abort(), 3000);
try {
const results = await Promise.all(operations);
console.log('All succeeded:', results);
} catch (error) {
console.log('Operations cancelled or failed');
}
const fs = require('fs/promises');
const { AbortController } = require('abort-controller');
// Cancel long-running file read
const controller = new AbortController();
const { signal } = controller;
// Cancel after 10 seconds
setTimeout(() => controller.abort(), 10000);
try {
const data = await fs.readFile('huge-file.txt', { signal });
console.log('File read complete');
} catch (error) {
if (error.name === 'AbortError') {
console.log('File read cancelled');
} else {
throw error;
}
}
Critical Breaking Changes
1. Unhandled Promise Rejections Now Throw
BREAKING: Application Crashes
In Node.js 15, the default mode for unhandledRejection changed from warn
to throw
. Unhandled promise rejections now crash your application instead of just logging a warning.
// ❌ This will CRASH your app in Node.js 15+
async function badCode() {
throw new Error('Unhandled rejection!');
}
badCode(); // No .catch() = process exit
// ✅ FIX 1: Add .catch() handler
badCode().catch(error => {
console.error('Caught error:', error);
});
// ✅ FIX 2: Use try/catch with await
async function goodCode() {
try {
await badCode();
} catch (error) {
console.error('Caught error:', error);
}
}
// ✅ FIX 3: Global unhandled rejection handler (last resort)
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Log to error tracking service
// Don't exit - let app continue (risky!)
});
// Real-world example: Express middleware
app.get('/api/data', async (req, res, next) => {
try {
const data = await fetchDataFromDB();
res.json(data);
} catch (error) {
next(error); // Pass to error handler middleware
}
});
2. Stream Constructor Changes
Readable and Writable streams now use stream.construct
API internally. This may affect custom stream implementations.
3. Deprecated APIs Removed
// ❌ REMOVED: server.connections
const server = http.createServer();
console.log(server.connections); // undefined in Node.js 15
// ✅ USE: server.getConnections() callback
server.getConnections((err, count) => {
console.log('Active connections:', count);
});
// ❌ REMOVED: node debug command
// node debug script.js // No longer works
// ✅ USE: node inspect
// node inspect script.js
// ❌ REMOVED: REPL deprecated functions
// repl.memory()
// repl.turnOffEditorMode()
// repl.parseREPLKeyword()
4. util.inspect() String Length Default
const util = require('util');
// Node.js 14: Default maxStringLength = Infinity (no truncation)
// Node.js 15: Default maxStringLength = 10000
const longString = 'a'.repeat(20000);
// ❌ Will be truncated to 10,000 chars in Node.js 15
console.log(util.inspect({ longString }));
// ✅ Explicitly set maxStringLength if needed
console.log(util.inspect({ longString }, {
maxStringLength: Infinity // No truncation
}));
console.log(util.inspect({ longString }, {
maxStringLength: 50 // Truncate to 50 chars
}));
Experimental Features
QUIC Protocol Support
Node.js 15 introduced experimental QUIC support, the protocol underlying HTTP/3, for faster and more reliable network connections.
# Compile Node.js with QUIC support
./configure --experimental-quic
make -j4
# Or use pre-built binary with flag
node --experimental-quic app.js
QUIC Benefits
- Faster connection establishment (0-RTT for repeat connections)
- Built-in encryption (TLS 1.3 by default)
- Better loss recovery than TCP
- Connection migration (switch networks without reconnecting)
N-API 7
N-API version 7 added new methods for working with ArrayBuffers and improved native addon compatibility.
N-API 7 Additions
- •
napi_detach_arraybuffer()
- •
napi_is_detached_arraybuffer()
- • Improved performance for native modules
Migration Guide: Node.js 14 → 15
Pre-Migration Checklist
# Step 1: Update Node.js
nvm install 15
nvm use 15
# Step 2: Clear npm cache and node_modules
rm -rf node_modules package-lock.json
npm cache clean --force
# Step 3: Reinstall with npm 7
npm install
# Step 4: Check for peer dependency warnings
npm ls
# Step 5: Run tests with unhandled rejection detection
NODE_OPTIONS='--unhandled-rejections=strict' npm test
# Step 6: Fix all unhandled rejections before deploying
# Add .catch() to all promise chains
# Wrap async functions in try/catch
# Step 7: Update CI/CD Node version
# .github/workflows/ci.yml
# - uses: actions/setup-node@v2
# with:
# node-version: '15'
Performance Improvements

Promise Performance
~15%
Faster Promise.all() and async/await
String Operations
~20%
String.replaceAll() optimizations
npm Install
~30%
Faster with new lockfile format
Key Takeaways
Major Wins
- npm 7 workspaces for monorepos
- V8 8.6 with Promise.any()
- Stable AbortController API
- Logical assignment operators
- Experimental QUIC support
Watch Out For
- Unhandled rejections now crash apps
- Peer dependencies auto-installed
- Deprecated APIs removed
- util.inspect() string truncation
- Stream constructor changes
Conclusion
Node.js 15 represented a significant evolution in the Node.js ecosystem, bringing modern JavaScript features and professional-grade tooling with npm 7 workspaces. While the breaking changes required careful migration planning, the benefits—especially for monorepo projects—made it a worthwhile upgrade.
For new projects: Consider using LTS versions (Node.js 14, 16, 18, 20) for production stability.
Learning from Node.js 15: Understanding this release helps appreciate the evolution of Node.js and prepares you for similar changes in future major versions.
Stay Updated on Node.js & JavaScript
Get weekly Node.js, JavaScript & DevOps insights in your inbox