Books/Node.js Essentials/Async JavaScript in Node.js

    Async JavaScript in Node.js

    Async JavaScript in Node.js

    Almost everything interesting in Node.js is asynchronous — API calls, database queries, file reads, network requests. Understanding async code is one of the most important skills for working with AI-generated backends.

    Why Async Matters

    When your server calls an AI API, that call might take 2-5 seconds. Without async, your server would freeze and couldn't handle any other requests during that time.

    Synchronous (blocking):
      Request 1 starts -> waits 3 seconds for API -> responds
                                                        Request 2 starts -> waits 3 seconds -> responds
      Total time: 6 seconds
    
    Asynchronous (non-blocking):
      Request 1 starts -> sends API call -> (keeps working)
      Request 2 starts -> sends API call -> (keeps working)
      Request 1 API returns -> responds
      Request 2 API returns -> responds
      Total time: ~3 seconds
    

    Node.js handles this through three patterns. Let's look at each one.

    Pattern 1: Callbacks (The Old Way)

    Callbacks are functions you pass to other functions, which get called when the work is done:

    import fs from "fs";
    
    // The callback pattern: function(error, result)
    fs.readFile("data.txt", "utf-8", (err, content) => {
      if (err) {
        console.error("Error:", err.message);
        return;
      }
      console.log("File content:", content);
    });
    
    console.log("This runs BEFORE the file is read!");

    Output:

    This runs BEFORE the file is read!
    File content: (contents of data.txt)
    

    The problem with callbacks? Callback hell — nested callbacks that become unreadable:

    // Callback hell — don't do this!
    fs.readFile("config.json", "utf-8", (err, config) => {
      if (err) return handleError(err);
      db.connect(JSON.parse(config), (err, connection) => {
        if (err) return handleError(err);
        connection.query("SELECT * FROM users", (err, users) => {
          if (err) return handleError(err);
          // Finally we have our users, 3 levels deep...
        });
      });
    });

    You'll rarely write callbacks in modern code, but you'll see them in older Node.js examples and libraries.

    Pattern 2: Promises (The Better Way)

    A Promise represents a value that will be available in the future. It's either:

    • Pending — still working
    • Fulfilled — done successfully (has a result)
    • Rejected — failed (has an error)
    // Creating a Promise
    function delay(ms) {
      return new Promise((resolve) => {
        setTimeout(() => resolve("Done!"), ms);
      });
    }
    
    // Using a Promise with .then() and .catch()
    delay(2000)
      .then((result) => {
        console.log(result);  // "Done!" (after 2 seconds)
      })
      .catch((error) => {
        console.error("Error:", error);
      });

    Chaining Promises

    Promises can be chained — each .then() returns a new Promise:

    fetch("https://api.example.com/users")
      .then((response) => response.json())
      .then((users) => {
        console.log("Got users:", users.length);
        return users.filter((u) => u.isActive);
      })
      .then((activeUsers) => {
        console.log("Active users:", activeUsers.length);
      })
      .catch((error) => {
        console.error("Something went wrong:", error);
      });

    This is cleaner than callbacks, but there's an even better way.

    Pattern 3: async/await (The Modern Way)

    async/await is syntactic sugar on top of Promises that makes async code look like normal, synchronous code. This is what you should use and what AI tools generate.

    // Mark a function as async
    async function getUsers() {
      // 'await' pauses execution until the Promise resolves
      const response = await fetch("https://api.example.com/users");
      const users = await response.json();
      return users;
    }
    
    // Arrow function version
    const getUsers = async () => {
      const response = await fetch("https://api.example.com/users");
      const users = await response.json();
      return users;
    };

    Compare the three styles side by side:

    // Callbacks
    fs.readFile("data.json", "utf-8", (err, raw) => {
      if (err) return console.error(err);
      const data = JSON.parse(raw);
      console.log(data);
    });
    
    // Promises (.then)
    fs.promises.readFile("data.json", "utf-8")
      .then((raw) => JSON.parse(raw))
      .then((data) => console.log(data))
      .catch((err) => console.error(err));
    
    // async/await (cleanest!)
    async function loadData() {
      const raw = await fs.promises.readFile("data.json", "utf-8");
      const data = JSON.parse(raw);
      console.log(data);
    }

    What to ask your AI: "Convert this callback-based code to async/await: [paste code]"

    Error Handling with try/catch

    When using async/await, you handle errors with try/catch — just like synchronous code:

    async function fetchUserData(userId: string) {
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
    
        if (!response.ok) {
          throw new Error(`HTTP error: ${response.status}`);
        }
    
        const user = await response.json();
        return user;
      } catch (error) {
        console.error("Failed to fetch user:", error.message);
        return null;  // Return a fallback value
      }
    }
    
    // Usage
    const user = await fetchUserData("123");
    if (user) {
      console.log("Got user:", user.name);
    } else {
      console.log("Could not load user");
    }

    Handling Errors in Express Routes

    app.get("/api/users/:id", async (req, res) => {
      try {
        const user = await db.getUser(req.params.id);
        if (!user) {
          return res.status(404).json({ error: "User not found" });
        }
        res.json(user);
      } catch (error) {
        console.error("Database error:", error);
        res.status(500).json({ error: "Internal server error" });
      }
    });

    Promise.all for Parallel Operations

    When you need to do multiple async things that don't depend on each other, run them in parallel with Promise.all:

    // Sequential — slow (each waits for the previous one)
    const users = await fetchUsers();      // 1 second
    const products = await fetchProducts(); // 1 second
    const orders = await fetchOrders();     // 1 second
    // Total: ~3 seconds
    
    // Parallel — fast (all run at the same time)
    const [users, products, orders] = await Promise.all([
      fetchUsers(),     // 1 second
      fetchProducts(),  // 1 second  (starts at the same time)
      fetchOrders(),    // 1 second  (starts at the same time)
    ]);
    // Total: ~1 second

    Other Promise Utilities

    // Promise.allSettled — wait for all, even if some fail
    const results = await Promise.allSettled([
      fetchUsers(),
      fetchProducts(),
      fetchOrders(),
    ]);
    
    results.forEach((result) => {
      if (result.status === "fulfilled") {
        console.log("Success:", result.value);
      } else {
        console.log("Failed:", result.reason);
      }
    });
    
    // Promise.race — return whichever finishes first
    const fastest = await Promise.race([
      fetchFromServer1(),
      fetchFromServer2(),
    ]);

    What to ask your AI: "I'm making three independent API calls. Convert them to run in parallel with Promise.all."

    Common Async Patterns in AI Apps

    Calling an AI API

    import Anthropic from "@anthropic-ai/sdk";
    
    const client = new Anthropic();
    
    async function askClaude(question: string): Promise<string> {
      try {
        const response = await client.messages.create({
          model: "claude-sonnet-4-20250514",
          max_tokens: 1024,
          messages: [{ role: "user", content: question }],
        });
        return response.content[0].text;
      } catch (error) {
        console.error("AI API error:", error);
        throw error;
      }
    }

    Database Query

    async function getUserWithPosts(userId: string) {
      const [user, posts] = await Promise.all([
        db.collection("users").doc(userId).get(),
        db.collection("posts").where("authorId", "==", userId).get(),
      ]);
    
      return {
        ...user.data(),
        posts: posts.docs.map((doc) => doc.data()),
      };
    }

    Retry Pattern

    async function fetchWithRetry(url: string, maxRetries = 3): Promise<any> {
      for (let attempt = 1; attempt <= maxRetries; attempt++) {
        try {
          const response = await fetch(url);
          if (!response.ok) throw new Error(`HTTP ${response.status}`);
          return await response.json();
        } catch (error) {
          console.log(`Attempt ${attempt}/${maxRetries} failed: ${error.message}`);
          if (attempt === maxRetries) throw error;
          // Wait before retrying (exponential backoff)
          await new Promise((r) => setTimeout(r, 1000 * attempt));
        }
      }
    }

    Common Mistakes

    MistakeFix
    Forgetting awaitYour variable will be a Promise instead of the actual value
    Using await outside an async functionWrap your code in an async function
    Not handling errorsAlways use try/catch with await
    Sequential when parallel is possibleUse Promise.all for independent operations
    Forgetting .json() on fetchfetch returns a Response — call .json() to get the data

    What's Next?

    You now understand async programming — one of the most important concepts in Node.js. The final tutorial is your Node.js Cheat Sheet — a quick reference for all the concepts and patterns covered in this book, plus AI prompts you can use right away.

    What to ask your AI: "I'm calling three APIs sequentially. Can you refactor this to use Promise.all and add proper error handling?"


    🌐 www.genai-mentor.ai