Skip to content

async_01 - Async Concepts

Your code is synchronous by default - one line finishes before the next starts
Node exists because blocking I/O wastes CPU cycles while your process stares at a disk spinning or a network packet arriving. Async in Node means: fire the operation , register a callback , and let the event loop handle other shit while you wait. The OS or libuv does the waiting , not your JavaScript thread

non-blocking I/O

JavaScript in Node runs single-threaded on the event loop
When you call fs.readFile() or http.get(), Node doesn't freeze and wait for the result. It delegates the I/O operation to the system kernel (via libuv's thread pool or epoll/kqueue) and continues executing the next lines. When the operation completes , a callback gets queued in the event loop's poll phase and your code resumes

const fs = require('fs')

// blocking - don't do this in production
const data = fs.readFileSync('/etc/passwd', 'utf8')
console.log('sync done:', data.length, 'bytes')

// non-blocking - this is the Node way
fs.readFile('/etc/passwd', 'utf8', (err, data) => {
  if (err) throw err
  console.log('async done:', data.length, 'bytes')
})

console.log('this runs BEFORE the file is read')

Run this and watch the output order. The console.log after the async read executes first because the callback hasn't fired yet. That's the whole point - Node doesn't wait

callbacks - the original async pattern

Before Promises existed , callbacks were the only game in town
You pass a function as an argument to an async operation. When the operation finishes , Node calls your function with the result (or error). Simple , elegant , and absolutely brutal to maintain at scale

error-first callback convention

This is the Node standard - every callback in core modules follows this pattern:

const fs = require('fs')

fs.readFile('/etc/hosts', 'utf8', (err, data) => {
  if (err) {
    console.error('file read failed:', err.message)
    return
  }
  console.log('file contents:', data)
})

First argument is always an error object (or null if successful). Second argument is the result. This convention exists because there's no try/catch for async callbacks - if you don't check err, the error silently disappears and your code continues like nothing happened

Security angle: Unhandled callback errors don't throw. They don't crash the process. They just... vanish. Your server returns a 200 with empty data instead of a 500 with an error log. You spend 3 hours debugging why the database returned nothing only to find a typo in the collection name that your callback swallowed

// The silent killer - never do this
fs.readFile('/etc/secrets/db_pass', 'utf8', (data) => {
  // data is actually an Error object, not the file contents
  // you just ignored the first argument
  connectToDatabase(data.trim())
})

callback hell

Nest callbacks inside callbacks inside callbacks and you get callback hell
Three async operations in sequence and your code starts looking like a sideways Christmas tree. Each level adds indentation and the error handling becomes a goddamn nightmare of repeated checks

const fs = require('fs')

// callback hell - real production code looks like this
fs.readFile('user.json', 'utf8', (err, userData) => {
  if (err) return console.error(err)
  const user = JSON.parse(userData)

  fs.readFile(`configs/${user.id}.json`, 'utf8', (err, configData) => {
    if (err) return console.error(err)
    const config = JSON.parse(configData)

    fs.readFile(`templates/${config.template}.html`, 'utf8', (err, templateData) => {
      if (err) return console.error(err)
      const output = templateData.replace('{{name}}', user.name)

      fs.writeFile(`output/${user.id}.html`, output, 'utf8', (err) => {
        if (err) return console.error(err)
        console.log('done - only took 16 lines for 4 operations')
      })
    })
  })
})

Every if (err) return is a landmine you might forget. Every callback creates a new closure scope. Refactoring means re-indenting everything. This is why Promises exist and why modern Node code avoids raw callbacks for control flow

callback patterns that still matter

Not everything has Promises. Event emitters , streams , and some native APIs still use callbacks:

  • Event emitters: server.on('request', handler) - the most common callback pattern in Node
  • Streams: readable.on('data', chunk => ...) - callbacks fire per data chunk
  • Limited concurrency: async.eachLimit and similar utilities from libraries like async (npm package , not keyword) manage callback-based concurrency
const http = require('http')

// Event emitter callbacks - still the pattern for HTTP
const server = http.createServer((req, res) => {
  // this callback fires on every request
  // req is a ReadableStream , res is a WritableStream
  let body = ''

  req.on('data', (chunk) => {
    body += chunk.toString()
  })

  req.on('end', () => {
    res.end(`received ${body.length} bytes`)
  })
})

server.listen(3000)

error-first in utility functions

You can (and should) write your own async functions using the error-first convention

const fs = require('fs')
const path = require('path')

function readConfig(configName, callback) {
  const configPath = path.join(__dirname, 'configs', `${configName}.json`)

  fs.readFile(configPath, 'utf8', (err, data) => {
    if (err) {
      // wrap with more context
      callback(new Error(`Failed to read config ${configName}: ${err.message}`))
      return
    }

    try {
      const config = JSON.parse(data)
      callback(null, config)
    } catch (parseErr) {
      callback(new Error(`Invalid JSON in ${configName}: ${parseErr.message}`))
    }
  })
}

// usage
readConfig('database', (err, config) => {
  if (err) {
    console.error('config load failed:', err.message)
    process.exit(1)
  }
  console.log('db config:', config.host, config.port)
})

Always return after calling the callback with an error. Otherwise the callback fires twice - once with the error and once with the result - and the consumer gets confused as hell

security implications

Async code introduces race conditions that synchronous code doesn't have

let balance = 1000

// two requests arrive at the same time
// both check balance , both see 1000
// both deduct 500 , both write 500
// you just gave away free money

function withdraw(amount, callback) {
  if (balance >= amount) {
    // async gap - another request can run here
    fs.writeFile('ledger.txt', String(balance - amount), () => {
      balance -= amount
      callback(null, balance)
    })
  } else {
    callback(new Error('insufficient funds'))
  }
}

The fix: use atomic operations , database transactions , or a mutex. Never trust async read-check-write patterns without synchronization

debugging async code

Async stack traces used to be garbage in Node
Promises improved this but callback-based code still produces stack traces that show where the callback was created , not where it's currently executing. Use --async-stack-traces flag in Node 16+ for better diagnostics. And for the love of god don't console.log your way through async bugs - use debug package or the built-in util.debuglog

# better async stack traces in Node 16+
node --async-stack-traces app.js

summary

  • Callbacks receive (err, result) - always check err first
  • Every async operation goes through the event loop , not the call stack
  • Callback hell is real - use Promises or async/await for new code
  • Unhandled callback errors are silent failures waiting to happen
  • Race conditions exist in async code - use proper synchronization

prerequisites

node_08_event_loop.md - you need to understand the event loop phases before callbacks make sense

next -> async_02_promises.md