Skip to main content

Command Palette

Search for a command to run...

Making Peace with Asynchronous JavaScript: From Blocking Code to Async/Await

Updated
13 min readView as Markdown

A practical guide to understanding why JavaScript behaves the way it does, and how async/await made our lives simpler.

1. The Restaurant Analogy

Imagine you walk into a small dosa stall in Bangalore. There is only one cook behind the counter. This cook is excellent, but he works alone. This single cook is your JavaScript engine. He can only make one dosa at a time.

Now, how he handles orders determines everything about your experience.


2. Synchronous Code: The Stubborn Cook

If the cook is synchronous, he takes your order, makes your dosa, serves it, and only then looks at the next customer. If someone asks for a coffee that takes five minutes to brew, every other customer waits. The queue grows. People get annoyed.

In code, this looks simple and predictable:

const fs = require('node:fs');

function makeBreakfast() {
    const data = fs.readFileSync('menu.txt', 'utf8'); // blocks here
    console.log('Menu loaded:', data);
    
    const eggs = cookEggs(); // blocks here
    console.log('Eggs ready:', eggs);
    
    console.log('Breakfast served');
}

makeBreakfast();
console.log('Doing other morning tasks...');

Output:

Menu loaded: Idli, Dosa, Coffee
Eggs ready: 2 fried eggs
Breakfast served
Doing other morning tasks...

Everything happens in order. The problem? If readFileSync takes time because the file is on a slow network drive, your entire program freezes. No other code runs. In a Node.js server, this means no other requests get handled. That single cook is staring at the coffee machine instead of taking new orders.

This is blocking code.


3. Why JavaScript Needs Asynchronous Behavior

JavaScript was born in the browser. It started as a simple scripting language to make web pages interactive. But soon, developers needed to do things like fetch data from a server, wait for user input, or delay execution. These operations take time, and if JavaScript waited for each one to finish before doing anything else, every web page would feel frozen.

Node.js brought JavaScript to the server, where the stakes got higher. A web server might handle thousands of connections simultaneously. If it blocked on every database query or file read, it would collapse under load. The Node.js documentation is very clear about this: the secret to Node.js scalability is that it uses a small number of threads to handle many clients, and you must structure your application to use them wisely .

So JavaScript needed a way to say: "Start this long task, but do not wait here. Let me know when it is done, and I will handle it then."

This is asynchronous code.


4. Asynchronous Code: The Smart Cook

The smart cook takes your coffee order, starts the machine, and immediately asks the next person what they want. When the coffee finishes, he serves it. No one waits unnecessarily.

In Node.js, this is the default for I/O operations:

const fs = require('node:fs');

function makeBreakfastAsync() {
    fs.readFile('menu.txt', 'utf8', (err, data) => {
        if (err) {
            console.error('Failed to load menu:', err);
            return;
        }
        console.log('Menu loaded:', data);
    });
    
    console.log('Started reading menu...');
}

makeBreakfastAsync();
console.log('Doing other morning tasks...');

Output:

Started reading menu...
Doing other morning tasks...
Menu loaded: Idli, Dosa, Coffee

Notice the order. The file reading starts, but the code moves on. The callback runs later, when the file is actually read. This is non-blocking.

But this style, while powerful, gets messy when you chain multiple operations. Callbacks inside callbacks inside callbacks. The infamous callback hell.


5. The Event Loop: The Kitchen Manager

To understand how this works, we need to meet the event loop. Think of it as the kitchen manager who watches everything. The cook (JavaScript engine) executes code from a call stack. When an asynchronous operation starts, like reading a file or setting a timer, Node.js hands it off to the system or a worker thread. The cook moves on.

When the file is ready, the kitchen manager places a note in a task queue: "File read done. Run this callback." The cook finishes whatever he is doing, checks the queue, and handles the note.

Here is how the event loop phases work in Node.js :

The event loop continuously cycles through these phases. Your synchronous code runs first. Then the loop picks up completed asynchronous tasks and executes their callbacks.


6. Visualizing Synchronous vs Asynchronous Execution

Let us look at a timeline. First, synchronous:

Everything waits in line. The thread is blocked for the entire duration.

Now, asynchronous:

The main thread starts the operation and moves on. The background work happens elsewhere. When it completes, the callback runs.


7. The Problem with Blocking Code in Real Applications

Let us see what happens when blocking code sneaks into a Node.js server:

const http = require('node:http');

const server = http.createServer((req, res) => {
    if (req.url === '/fast') {
        res.end('Quick response');
        return;
    }
    
    if (req.url === '/slow') {
        // Simulate a heavy calculation
        let sum = 0;
        for (let i = 0; i < 1e9; i++) {
            sum += i;
        }
        res.end(`Heavy result: ${sum}`);
        return;
    }
});

server.listen(3000, () => {
    console.log('Server running on port 3000');
});

If someone hits /slow, the event loop is stuck in that for loop. Every other request, even to /fast, waits. The server appears dead. This is why the Node.js documentation warns: "Node.js is fast when the work associated with each client at any given time is small" .

A malicious user could intentionally hit /slow repeatedly, causing a denial of service. In production, this is catastrophic.


8. Promises: A Step Forward

Promises improved things by giving us a structured way to handle asynchronous results. Instead of nested callbacks, we got .then() chains. But the code still did not look like normal code. It looked like a staircase:

const fs = require('node:fs').promises;

function readConfig() {
    return fs.readFile('config.json', 'utf8')
        .then(data => {
            const config = JSON.parse(data);
            return fs.readFile(config.templatePath, 'utf8');
        })
        .then(template => {
            console.log('Template loaded');
            return template.replace('{{name}}', 'Rahul');
        })
        .then(final => {
            console.log('Final output:', final);
            return final;
        })
        .catch(err => {
            console.error('Something broke:', err);
            throw err;
        });
}

This is better than callbacks, but still noisy. Every .then() is a mental context switch. Error handling with .catch() works, but it is easy to miss edge cases. The code does not read top-to-bottom like synchronous code.


9. Async/Await: Syntactic Sugar That Changed Everything

In 2017, ES2017 introduced async/await. It is not new magic under the hood. It is syntactic sugar over Promises. But what beautiful sugar it is.

The same code now reads like this:

const fs = require('node:fs').promises;

async function readConfig() {
    try {
        const data = await fs.readFile('config.json', 'utf8');
        const config = JSON.parse(data);
        
        const template = await fs.readFile(config.templatePath, 'utf8');
        console.log('Template loaded');
        
        const final = template.replace('{{name}}', 'Rahul');
        console.log('Final output:', final);
        
        return final;
    } catch (err) {
        console.error('Something broke:', err);
        throw err;
    }
}

The async keyword tells JavaScript: "This function always returns a Promise. Even if you return a plain value, wrap it in a Promise."

The await keyword tells JavaScript: "Pause execution of this async function here until the Promise resolves. But do not block the event loop. Let other code run meanwhile."

This is the crucial distinction. await does not block the event loop. It only pauses the current async function. The event loop keeps spinning, handling other requests, timers, and I/O.


10. How Async Functions Actually Work

When you write:

async function fetchUserData(userId) {
    const response = await fetchFromDatabase(userId);
    const processed = await processUser(response);
    return processed;
}

JavaScript transforms this roughly into Promise chains under the hood. The function returns immediately with a Promise. Each await splits the function into segments that execute between event loop ticks.

Here is the execution flow:

The function appears linear to you, but under the hood it is cooperating with the event loop at every await boundary.


11. Error Handling with Async/Await

One of the biggest wins with async/await is error handling. With Promises, you had to remember to add .catch() everywhere. With async/await, you use familiar try/catch:

const fs = require('node:fs').promises;

async function backupFile(sourcePath, backupPath) {
    try {
        const data = await fs.readFile(sourcePath, 'utf8');
        console.log(`Read ${data.length} bytes from source`);
        
        await fs.writeFile(backupPath, data);
        console.log('Backup created successfully');
        
        return { success: true, bytes: data.length };
    } catch (error) {
        if (error.code === 'ENOENT') {
            console.error('Source file not found');
            return { success: false, reason: 'missing_source' };
        }
        
        if (error.code === 'EACCES') {
            console.error('Permission denied for backup path');
            return { success: false, reason: 'permission_denied' };
        }
        
        console.error('Unexpected error during backup:', error.message);
        throw error; // Re-throw for upstream handling
    }
}

// Usage
backupFile('important.txt', 'backup.txt')
    .then(result => console.log('Result:', result))
    .catch(err => console.error('Fatal:', err));

Because async functions always return Promises, calling code can still use .then() and .catch() if needed. But inside the function, you write normal-looking code with normal-looking error handling.


12. Promise vs Async/Await: A Direct Comparison

Let us look at a realistic Node.js example: reading a configuration, validating it, fetching related data, and writing a log.

With Promises:

const fs = require('node:fs').promises;

function initializeService() {
    return fs.readFile('service.config', 'utf8')
        .then(raw => JSON.parse(raw))
        .then(config => {
            if (!config.apiKey) {
                throw new Error('Missing API key');
            }
            return config;
        })
        .then(config => {
            return fetchDatabaseUrl(config.env)
                .then(dbUrl => ({ ...config, dbUrl }));
        })
        .then(fullConfig => {
            return fs.writeFile('startup.log', `Started with ${fullConfig.dbUrl}`)
                .then(() => fullConfig);
        })
        .catch(err => {
            console.error('Initialization failed:', err.message);
            process.exit(1);
        });
}

With Async/Await:

const fs = require('node:fs').promises;

async function initializeService() {
    try {
        const raw = await fs.readFile('service.config', 'utf8');
        const config = JSON.parse(raw);
        
        if (!config.apiKey) {
            throw new Error('Missing API key');
        }
        
        const dbUrl = await fetchDatabaseUrl(config.env);
        const fullConfig = { ...config, dbUrl };
        
        await fs.writeFile('startup.log', `Started with ${fullConfig.dbUrl}`);
        
        return fullConfig;
    } catch (err) {
        console.error('Initialization failed:', err.message);
        process.exit(1);
    }
}

The async/await version is not just shorter. It is easier to debug. You can set breakpoints on individual lines. You can read it like a story. The Promise version requires you to mentally trace through .then() chains and remember what each closure returns.


13. Parallel Execution with Async/Await

A common misconception is that async/await forces everything to run sequentially. It does, if you use it naively:

// Slow: runs one after another
async function fetchSequentially() {
    const users = await fetchUsers();      // 2 seconds
    const orders = await fetchOrders();    // 2 seconds
    const products = await fetchProducts(); // 2 seconds
    return { users, orders, products };    // Total: 6 seconds
}

But if these operations are independent, you can run them in parallel using Promise.all:

// Fast: runs simultaneously
async function fetchInParallel() {
    const [users, orders, products] = await Promise.all([
        fetchUsers(),
        fetchOrders(),
        fetchProducts()
    ]);
    return { users, orders, products };    // Total: ~2 seconds
}

This is where understanding Promises still matters. async/await does not replace Promises. It sits on top of them. You still need to know when to use Promise.all, Promise.race, or Promise.allSettled.


14. A Complete Node.js Example: Building a Small API

Let us put everything together in a realistic Express-like scenario:

const http = require('node:http');
const fs = require('node:fs').promises;

// Simulated database
const db = {
    async getUser(id) {
        await new Promise(r => setTimeout(r, 100)); // Simulate latency
        if (id === '404') return null;
        return { id, name: 'Rahul Sharma', email: 'rahul@example.com' };
    },
    
    async getOrders(userId) {
        await new Promise(r => setTimeout(r, 150));
        return [
            { id: 1, total: 2500, items: ['Laptop stand', 'USB hub'] },
            { id: 2, total: 800, items: ['Notebook'] }
        ];
    }
};

async function handleGetUser(req, res, userId) {
    try {
        // Parallel fetch where possible
        const [user, orders] = await Promise.all([
            db.getUser(userId),
            db.getOrders(userId)
        ]);
        
        if (!user) {
            res.writeHead(404, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({ error: 'User not found' }));
            return;
        }
        
        // Sequential: log after fetch
        await fs.appendFile('access.log', `Fetched user \({userId} at \){new Date().toISOString()}\n`);
        
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({
            user,
            orders,
            fetchedAt: new Date().toISOString()
        }));
        
    } catch (err) {
        console.error('Request failed:', err);
        res.writeHead(500, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'Internal server error' }));
    }
}

const server = http.createServer((req, res) => {
    const match = req.url.match(/^\/users\/(.+)$/);
    if (req.method === 'GET' && match) {
        handleGetUser(req, res, match[1]);
        return;
    }
    
    res.writeHead(404);
    res.end('Not found');
});

server.listen(3000, () => {
    console.log('API server running on port 3000');
});

Notice how the request handler itself is not async. It fires handleGetUser and moves on. That function internally uses await, but the event loop is never blocked. Other requests keep flowing in.


15. Common Pitfalls to Avoid

Even with async/await, there are traps:

Forgetting await on a Promise-returning function:

async function broken() {
    const result = fs.readFile('file.txt', 'utf8'); // Forgot await!
    console.log(result); // Logs: Promise { <pending> }
}

Not handling rejections in parallel operations:

// If one fails, everything fails
const [a, b] = await Promise.all([fetchA(), fetchB()]);

// Better: handle individually
const results = await Promise.allSettled([fetchA(), fetchB()]);

Blocking inside an async function:

async function stillBlocking() {
    await fs.readFile('small.txt'); // Non-blocking, good
    
    // But this blocks the event loop!
    for (let i = 0; i < 1e9; i++) {
        heavyCalculation();
    }
}

Remember, async/await makes asynchronous code look synchronous. It does not make synchronous code asynchronous. A heavy loop inside an async function still blocks everything.


16. The Async Task Queue Concept

Here is a final diagram showing how multiple async operations queue up and resolve:

The event loop is the orchestrator. Your async functions are the performers. They step on stage, do their part, step off when waiting, and come back when called.


17. Summary

Synchronous code is predictable but blocking. Asynchronous code is necessary because JavaScript is single-threaded and I/O operations take time. The event loop enables this non-blocking behavior by managing a queue of callbacks.

Promises improved upon callbacks, but async/await improved upon Promises by letting us write asynchronous code that reads linearly. It is syntactic sugar, but the kind that makes your code maintainable, debuggable, and pleasant to read.

The key rules to remember:

  • Never block the event loop with heavy synchronous work.

  • await pauses your function, not your entire program.

  • Use Promise.all for independent parallel operations.

  • Always handle errors with try/catch inside async functions.

  • async/await and Promises are friends, not replacements for each other.

Once you internalize these patterns, writing Node.js applications feels natural. The single cook in your kitchen becomes a master of multitasking, and your customers stay happy.

Happy coding.

More from this blog