Node.js 8 is shipping with full async/await support natively, no Babel required. This is the culmination of a multi-year journey from callback hell through Promises to finally having sane async code. But shipping async/await doesn't mean Node.js codebases suddenly become clean—migration is the hard part.
What's New
Node.js 8 includes V8 5.8, which supports async/await from ES2017. You can write this code without any build step:
const fs = require('fs-promise');
async function readConfig() {
try {
const data = await fs.readFile('config.json', 'utf8');
return JSON.parse(data);
} catch (error) {
console.error('Failed to read config:', error);
throw error;
}
}
async function main() {
const config = await readConfig();
console.log('Config loaded:', config);
}
main().catch(console.error);
This is dramatically cleaner than callbacks or even raw Promises. The code reads top-to-bottom, errors propagate naturally, and debugging actually works.
The Migration Reality
Here's what doesn't happen: existing Node.js apps don't magically refactor themselves. Most Node.js code still uses callbacks because that's what the core APIs use.
The core fs, http, and other modules are still callback-based:
const fs = require('fs');
// Still the primary API
fs.readFile('file.txt', (err, data) => {
if (err) throw err;
console.log(data);
});
To use async/await, you need to wrap callbacks in Promises:
const util = require('util');
const fs = require('fs');
const readFile = util.promisify(fs.readFile);
async function read() {
const data = await readFile('file.txt');
console.log(data);
}
Node.js 8 includes util.promisify to make this easier, but it's still manual work. Libraries need to update. Internal utilities need wrapping. Callback-based middleware needs conversion.
The Express Problem
Express, the most popular Node.js framework, doesn't natively support async route handlers. This doesn't work the way you'd expect:
app.get('/data', async (req, res) => {
const data = await fetchData();
res.json(data); // What if fetchData throws?
});
If fetchData() throws, the error won't be caught by Express's error handling. You need a wrapper:
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
app.get('/data', asyncHandler(async (req, res) => {
const data = await fetchData();
res.json(data);
}));
This works, but it's boilerplate. Frameworks like Koa have better async support, but most Node.js apps are on Express, and Express isn't changing quickly.
Performance Considerations
async/await is syntactic sugar over Promises, which means it has the same performance characteristics. For most applications, this is fine—async/await is fast enough.
But for high-throughput services, the overhead matters:
- Promises are slower than callbacks (more allocations, GC pressure)
- Each
awaitcreates a microtask - Deep async call stacks can impact performance
In practice, the readability and maintainability benefits outweigh the performance cost for 95% of applications. For the 5% where it matters (high-frequency trading, real-time systems), callbacks might still make sense.
Error Handling Evolution
One of async/await's biggest wins is error handling. With callbacks, error handling is fragmented:
// Callback hell with error checks
getData((err, data) => {
if (err) return handleError(err);
processData(data, (err, result) => {
if (err) return handleError(err);
saveResult(result, (err) => {
if (err) return handleError(err);
done();
});
});
});
With async/await, errors are just exceptions:
async function workflow() {
try {
const data = await getData();
const result = await processData(data);
await saveResult(result);
} catch (error) {
handleError(error);
}
}
This is how synchronous code works. Our brains understand it. This alone justifies async/await.
Debugging Improvements
Stack traces with callbacks are a nightmare. With Promises, they're better but still convoluted. With async/await and V8's improvements, stack traces finally make sense:
Error: Database connection failed
at query (db.js:15:11)
at fetchUser (user.js:23:9)
at main (index.js:42:5)
Each await point preserves the call stack. You can see where the error originated and how you got there. This is huge for production debugging.
Ecosystem Readiness
The ecosystem is catching up:
- Most popular libraries now return Promises
util.promisifyhelps with legacy APIs- New libraries are async/await first
- Testing frameworks support async tests
But migration takes time. Popular libraries like request are still callback-based (they'll probably add Promise support eventually). Internal company code is 90% callbacks. The ecosystem is in a multi-year transition.
Should You Migrate?
For new projects: absolutely use async/await from day one. There's no reason to write new callback code in 2017.
For existing projects: it's complicated.
- Greenfield features: write with async/await
- Bug fixes: don't refactor while fixing bugs
- Major refactors: consider migrating modules incrementally
- Hot paths: measure before changing
Don't rewrite everything overnight. That's a recipe for introducing bugs. But don't keep writing callback code either. Gradual migration is the answer.
The Bigger Trend
Node.js is maturing. The language is stabilizing around async/await. The ecosystem is converging. The wild experimentation phase is over.
This is good for businesses building on Node.js—less churn, more stability. But it's also less exciting than the early days when everything was changing constantly.
The price of maturity is less novelty. But the benefit is production readiness.
For more on Node.js 8 features, see the official documentation.