Module F-4 — Async JavaScript: Callbacks, Promises, and the Event Loop Intro
What this module covers: Asynchronous programming is the single hardest concept for developers coming to Node.js. Not because the ideas are complicated — they aren't — but because Node.js inherited a pile of historical patterns (callbacks, then Promises, then async/await) and you will encounter all three in the wild. This module explains all of them, why each one exists, and how to think about the event loop without needing to know its internals yet. The deep event loop mechanics are in Phase 3. Here you get the mental model that makes everything else in Phase 1 and 2 work.
Why Asynchronous Programming Exists
Consider this: you ask a database for some rows. On a fast network, the database responds in 1–5 milliseconds. In that 1–5ms, your CPU is not computing anything — it is idle, waiting for the network packet to arrive.
In a traditional synchronous program (or a thread-per-request server), that thread just sits there blocked. It cannot handle anything else while it waits. If 100 requests all arrive at once and each takes 5ms of I/O, you need 100 threads running simultaneously.
Node.js takes a different approach: register a callback or a Promise, then go do other work while waiting. When the I/O completes, execute the registered function. One thread. No waiting. This is the core idea.
You do not need to understand how the event loop implements this at a low level right now — that is Phase 3. What you need to understand now is the programming model: how you write code that does not block.
The Callback Pattern
Callbacks are the original mechanism for async in Node.js. A callback is simply a function you pass as an argument, to be called when some work is done.
Analogy: A callback is a claim ticket at a dry cleaner — you hand off the work and walk away, and the shop calls your number when it's done instead of making you stand at the counter.
javascript
Output:
text
The key thing: code after fs.readFile() does not wait for the file. Node.js fires off the file read, moves on, and calls your callback later when the data is ready.
The error-first convention: Node.js callbacks always receive (err, result). If err is not null, something went wrong. Always check it first.
javascript
The Problem: Callback Hell
Callbacks work fine for a single operation. Problems start when you need to chain operations — do this, then that, then the other thing:
javascript
This rightward drift — each operation nesting inside the previous one — is called callback hell or the pyramid of doom. It is:
Hard to read
Hard to maintain
Hard to add error handling to each level
Impossible to easily add parallel operations
Promises were invented to solve this.
Promises
A Promise is an object that represents the eventual result of an asynchronous operation. It is either:
Pending — the operation is still in progress
Fulfilled — the operation completed successfully, and the Promise has a value
Rejected — the operation failed, and the Promise has a reason (an error)
javascript
Using a Promise with .then() and .catch()
javascript
The key improvement over callbacks: errors flow to a single .catch() instead of needing to be checked at every level. And .then() chains are flat rather than nested.
Chaining Promises (the right way)
javascript
Flat. Readable. One .catch() handles any failure in the chain.
The Classic Bug: Forgetting to return Inside .then()
Chaining only works if every .then() that starts new async work actually returns the Promise it creates. Forget the return, and the chain silently stops waiting for that step:
javascript
The nested, un-returned version isn't just slower to notice a bug in — it also breaks error handling: a rejection from the inner Promise never reaches the outer .catch(), because the outer chain never linked to it in the first place. The fix is always the same: if a .then() callback kicks off async work, return it.
Promise combinators
When you need multiple operations to run at the same time and wait for all of them:
javascript
From production: A blockchain indexer needed the balances of 500 addresses and used Promise.all to fetch them in parallel — the pattern shown above for users/products/config. One of the 500 RPC calls to the node provider failed. Promise.all does exactly what it's documented to do: it rejects as soon as any one input rejects, immediately, without waiting for or reporting on the others. The other 499 calls had already succeeded by then — real answers, sitting in memory — but Promise.all's rejection discarded all of them, and the whole 500-address batch had to be retried from scratch. Every successful RPC call in that batch had burned a unit of the provider's rate-limited quota for nothing. The fix was to switch to Promise.allSettled, which waits for every Promise to settle (success or failure) and hands back a per-item result — so the 499 successes could be kept and only the one failed address needed a retry.
A related trap with Promise.race and Promise.any: both combinators resolve as soon as one input Promise settles favorably, but the other Promises you passed in don't stop running — they're still in flight. If one of those "losing" Promises later rejects, and nothing else is attached to it, you get an unhandled rejection even though your code looks like it handled the outcome correctly. This is easy to miss because the race/any call itself succeeded. If you fire off Promises you don't otherwise track, attach a no-op .catch() to each so a late rejection doesn't surface as an unhandled rejection warning (or crash your process, depending on your unhandledRejection handling).
async/await: The Modern Way
async/await is syntactic sugar over Promises. Under the hood, it is exactly the same — an async function returns a Promise, and await pauses execution of that function (not the entire process) until a Promise resolves.
javascript
Error handling with try/catch
javascript
A subtlety worth internalising: notice that if (!order) throw new Error(...) uses a plain synchronous throw — not reject(). Inside an async function, that's fine: a synchronous throw doesn't produce an uncaught exception that crashes your process. Because an async function always returns a Promise, JavaScript automatically converts any thrown error (synchronous or not) into that Promise's rejection. The try/catch above catches it exactly the same way it catches a rejected await. This is different from a plain (non-async) function, where an uncaught synchronous throw really can crash the process if nothing catches it.
Common async/await mistakes
Forgetting await:
javascript
Sequential when you should be parallel:
javascript
The rule: if operation B does not need the result of operation A, run them in parallel with Promise.all.
Unhandled rejections:
javascript
Cancelling In-Flight Work: AbortController
Everything so far assumes an async operation, once started, runs to completion. But sometimes you need to cancel it early — a user navigates away before a fetch finishes, or a request handler times out and you don't want to keep waiting on a slow downstream call. AbortController is the standard (browser and Node.js) mechanism for this:
javascript
controller.signal is an AbortSignal you pass to any API that supports cancellation (fetch, many database drivers, Node's own fs and stream APIs). Calling controller.abort() notifies every consumer of that signal to stop what it's doing. You don't need to reach for this in every script, but as soon as you're handling requests with timeouts, this is the mechanism Node.js and the web platform standardised on — not a bespoke cancelled flag you check manually.
The Event Loop: A First Look
You do not need the full mechanics yet — that is Phase 3. But you need the mental model to understand why the code above works the way it does.
Node.js runs your JavaScript on a single thread. That thread runs a loop: check for work → run work → check for more work → run more work → repeat. This is the event loop.
When you await db.query(...), Node.js does not block the thread. It:
Hands the database call to libuv (the C library beneath Node.js)
libuv tells the OS "make a TCP connection, send this query, call me when you have a response"
The event loop continues — handles other requests, runs timers, processes other callbacks
When the OS signals the database has responded, libuv queues the callback
The event loop picks it up, resumes your async function from the line after the await
The result: one thread can handle thousands of simultaneous I/O operations, because during the I/O wait it is doing other work.
text
This is why you should never do CPU-heavy work in Node.js without care — a long-running computation does block the event loop and stops everything else from running. More on this in Phase 3.
util.promisify: Converting Callbacks to Promises
Many older Node.js APIs and npm packages use the callback pattern. The util.promisify function wraps them to return a Promise:
javascript
util.promisify works with any function that follows the error-first callback convention (err, result) => {}. Modern Node.js modules (fs/promises, etc.) are already Promise-based, but you will encounter older libraries that need this treatment.
Practical Pattern: Top-Level async/await
In modern Node.js (v14.8+ with ESM, or inside any async function), you can use await at the top level of your entry file:
javascript
For CommonJS entry files, wrap in an immediately-invoked async function:
javascript
Always attach a .catch() to the top-level call so startup errors don't produce silent unhandled rejections.
Summary
Callbacks are functions passed as arguments, called when async work completes. They follow (err, result) convention. They still appear in older code and libraries — you need to be able to read them.
Promises represent a future value. .then() for success, .catch() for errors. They chain flat instead of nesting. Promise.all runs operations in parallel.
async/await is Promises with cleaner syntax. async functions return Promises. await pauses the function (not the process) until a Promise resolves. Use try/catch for error handling.
Use async/await for all new code. Fall back to .then()/.catch() when you can't use await (rare). Use util.promisify to wrap callback-based libraries.
Never block the event loop. Any synchronous operation that takes significant time will stop Node.js from handling other work. This is why I/O must be async and CPU-heavy work needs special handling.
Promise.all for parallel operations. If two operations don't depend on each other, run them simultaneously — it's often 2–5× faster than sequential awaits.
Next: npm and the ecosystem — package.json, semantic versioning, the lock file, and the tools every Node.js project installs on day one.
Knowledge Check
When fetching data from two independent services using async/await, what is the most efficient approach?
What happens if you forget to use the await keyword when calling an async function that queries a database?
How does the Node.js event loop handle a database query executed with await?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
const fs =require('node:fs');// readFile takes a path, options, and a callbackfs.readFile('./users.json','utf8',function(err, data){// This function runs AFTER the file has been readif(err){console.error('Error reading file:', err.message);return;}const users =JSON.parse(data);console.log('Loaded', users.length,'users');});// This line runs IMMEDIATELY — before the file is readconsole.log('Waiting for file...');
Waiting for file...
Loaded 42 users
functionhandleResult(err, result){if(err){// Handle the error and return — don't fall throughconsole.error(err);return;}// Only reach here if there was no errorconsole.log(result);}
fs.readFile('./config.json','utf8',function(err, configData){if(err)returnconsole.error(err);const config =JSON.parse(configData); db.query('SELECT * FROM users WHERE active = $1',[true],function(err, users){if(err)returnconsole.error(err);sendEmail(users[0].email,'Welcome', config.welcomeMessage,function(err){if(err)returnconsole.error(err); fs.writeFile('./sent.log', users[0].email+'\n',{flag:'a'},function(err){if(err)returnconsole.error(err);console.log('Done!');// We are now 4 levels deep — this is "callback hell"});});});});
// A function that returns a PromisefunctionreadJsonFile(filePath){returnnewPromise((resolve, reject)=>{ fs.readFile(filePath,'utf8',(err, data)=>{if(err){reject(err);// Operation failedreturn;}try{resolve(JSON.parse(data));// Operation succeeded}catch(parseErr){reject(parseErr);}});});}
readJsonFile('./config.json').then(config=>{console.log('Config loaded:', config);return config;// Pass to the next .then()}).then(config=>{console.log('Port:', config.port);}).catch(err=>{// Catches errors from ANY .then() aboveconsole.error('Something went wrong:', err.message);}).finally(()=>{// Always runs, whether success or failureconsole.log('Done');});
// Each .then() can return a new Promise — they chain automaticallyreadJsonFile('./config.json').then(config=> db.query('SELECT * FROM users WHERE active = $1',[true])).then(users=>sendEmail(users[0].email,'Welcome','Hello!')).then(()=> fs.promises.appendFile('./sent.log','email sent\n')).then(()=>console.log('Done!')).catch(err=>console.error('Failed:', err.message));
// ❌ WRONG — the chain doesn't wait for sendEmail to finishreadJsonFile('./config.json').then(config=>{ db.query('SELECT * FROM users WHERE active = $1',[true]).then(users=>{sendEmail(users[0].email,'Welcome', config.welcomeMessage);// no return!});// Execution falls through to here immediately — sendEmail may still be in flight}).then(()=>console.log('Done!'))// This can log BEFORE the email actually sends.catch(err=>console.error('Failed:', err.message));// This won't catch a sendEmail failure// ✅ CORRECT — return the inner Promise so it joins the outer chainreadJsonFile('./config.json').then(config=>{return db.query('SELECT * FROM users WHERE active = $1',[true]).then(users=>sendEmail(users[0].email,'Welcome', config.welcomeMessage));}).then(()=>console.log('Done!')).catch(err=>console.error('Failed:', err.message));
// Run all in parallel, wait for all to succeed// Fails fast if any one rejectsconst[users, products, config]=awaitPromise.all([ db.query('SELECT * FROM users'), db.query('SELECT * FROM products'),readJsonFile('./config.json'),]);// Wait for all, don't fail if some rejectconst results =awaitPromise.allSettled([fetchFromServiceA(),fetchFromServiceB(),fetchFromServiceC(),]);results.forEach(result=>{if(result.status==='fulfilled'){console.log('Success:', result.value);}else{console.error('Failed:', result.reason);}});// Race — resolves/rejects with whichever finishes firstconst fastest =awaitPromise.race([fetchFromRegion('us-east'),fetchFromRegion('eu-west'),]);// First to SUCCEED (ignores rejections until all reject)const firstSuccess =awaitPromise.any([attemptMethod1(),attemptMethod2(),attemptMethod3(),]);
// This function returns a Promise automaticallyasyncfunctionloadAndProcessUsers(){// await pauses HERE until the Promise resolvesconst config =awaitreadJsonFile('./config.json');// Execution continues here after the file is readconst users =await db.query('SELECT * FROM users WHERE active = $1',[true]);return users.filter(u=> u.email.endsWith(config.domain));}// Call it like a regular function, but it returns a PromiseloadAndProcessUsers().then(users=>console.log(users)).catch(err=>console.error(err));// Or await it inside another async functionasyncfunctionmain(){const users =awaitloadAndProcessUsers();console.log(users);}
asyncfunctionprocessOrder(orderId){try{const order =await db.query('SELECT * FROM orders WHERE id = $1',[orderId]);if(!order)thrownewError(`Order ${orderId} not found`);awaitchargePayment(order.amount, order.paymentMethod);awaitsendConfirmationEmail(order.userEmail);await db.query('UPDATE orders SET status = $1 WHERE id = $2',['completed', orderId]);return{success:true};}catch(err){// Catches any error from any await aboveconsole.error('Order processing failed:', err.message);// You can re-throw, return an error object, or handle herereturn{success:false,error: err.message};}}
// ❌ WRONG — result is a Promise object, not the dataasyncfunctiongetUser(id){const user = db.findById(id);// Missing await!console.log(user.name);// TypeError: Cannot read 'name' of Promise}// ✅ CORRECTasyncfunctiongetUser(id){const user =await db.findById(id);console.log(user.name);}
// ❌ SLOW — these have no dependency on each other but run sequentiallyasyncfunctionloadDashboard(userId){const user =await db.getUser(userId);// waits 10msconst orders =await db.getOrders(userId);// waits 10ms AFTER userconst messages =await db.getMessages(userId);// waits 10ms AFTER orders// Total: ~30ms}// ✅ FAST — run all three in parallelasyncfunctionloadDashboard(userId){const[user, orders, messages]=awaitPromise.all([ db.getUser(userId), db.getOrders(userId), db.getMessages(userId),]);// Total: ~10ms (all run simultaneously)}
// ❌ WRONG — if this Promise rejects, it's unhandledasyncfunctionbackground(){awaitsomeOperationThatMightFail();}background();// No .catch(), no try/catch in the caller// ✅ CORRECT — always handle rejectionsbackground().catch(err=>console.error('Background task failed:', err));// Or at the top levelprocess.on('unhandledRejection',(reason)=>{console.error('Unhandled rejection:', reason); process.exit(1);});
const controller =newAbortController();// Give the request 3 seconds, then cancel itconst timeout =setTimeout(()=> controller.abort(),3000);try{const response =awaitfetch('https://api.example.com/slow-endpoint',{signal: controller.signal,});const data =await response.json();console.log(data);}catch(err){if(err.name==='AbortError'){console.error('Request timed out and was aborted');}else{throw err;}}finally{clearTimeout(timeout);}
Your Code: │ Event Loop: │ OS / libuv:
│ │
await db.query() │ → queue DB call │ → TCP connection open
│ → handle request 2 │ → query sent
│ → handle request 3 │ → waiting for response
│ → run timer │
│ ← DB response ready │ ← response arrived
resume from await │ ← run your callback │
const util =require('node:util');const fs =require('node:fs');const{ exec }=require('node:child_process');// Wrap callback-based functionsconst readFile = util.promisify(fs.readFile);const execAsync = util.promisify(exec);// Now use with async/awaitconst data =awaitreadFile('./package.json','utf8');const{ stdout }=awaitexecAsync('git log --oneline -5');console.log(stdout);
// main.mjs (or .js with "type": "module")import{ readFile }from'node:fs/promises';// Top-level await — works in ESMconst config =awaitreadFile('./config.json','utf8');console.log(JSON.parse(config));