Performance

Async & Non-Blocking I/O

Processing I/O without blocking the thread, keeping CPU utilisation high.

Overview

Asynchronous I/O allows a process to initiate an I/O operation (disk read, network request) and continue executing other work while waiting for the result, rather than blocking the thread. Node.js is built on non-blocking I/O via libuv's event loop; Ruby has async-io via the Async gem and Fibers; Go uses goroutines with channel-based I/O. The key benefit is handling thousands of concurrent I/O operations on a single thread without thread-per-connection overhead.

Origin

Non-blocking I/O has roots in Unix select() and poll() system calls (1970s-1980s). epoll (Linux 2.5.44, 2002) and kqueue (FreeBSD, 2000) provided efficient scalable I/O multiplexing. Node.js (Ryan Dahl, 2009) built a JavaScript runtime on non-blocking I/O as its core design. The C10K problem (Dan Kegel, 1999) articulated the need to handle 10,000 concurrent connections, motivating non-blocking I/O research.

Examples

Concurrent I/O with Promise.all vs sequential in TypeScript

interface ProductDetails {
  product: { id: string; name: string };
  inventory: { qty: number };
  reviews: { avgRating: number; count: number };
}

// SEQUENTIAL: total time = t_product + t_inventory + t_reviews
async function getProductDetailsSequential(id: string): Promise<ProductDetails> {
  const product = await fetchProduct(id);       // ~50ms
  const inventory = await fetchInventory(id);   // ~30ms
  const reviews = await fetchReviews(id);       // ~40ms
  return { product, inventory, reviews };        // total ~120ms
}

// CONCURRENT: total time = max(t_product, t_inventory, t_reviews)
async function getProductDetailsConcurrent(id: string): Promise<ProductDetails> {
  const [product, inventory, reviews] = await Promise.all([
    fetchProduct(id),    // ~50ms
    fetchInventory(id),  // ~30ms (runs concurrently)
    fetchReviews(id),    // ~40ms (runs concurrently)
  ]);
  return { product, inventory, reviews }; // total ~50ms
}

// CONCURRENT with independent error handling
async function getProductDetailsResilient(id: string) {
  const [product, inventory, reviews] = await Promise.allSettled([
    fetchProduct(id),
    fetchInventory(id),
    fetchReviews(id),
  ]);
  return {
    product: product.status === 'fulfilled' ? product.value : null,
    inventory: inventory.status === 'fulfilled' ? inventory.value : null,
    reviews: reviews.status === 'fulfilled' ? reviews.value : null,
  };
}

The concurrent version runs all three fetches simultaneously. On a 120ms total with sequential execution, concurrent reduces wall-clock time to 50ms, a 2.4x speedup for this pattern. Promise.allSettled allows partial results when individual sources can fail independently.

Async streams with the Async gem in Ruby

require 'async'
require 'async/http/internet'

# Sequential: each request waits for the previous
def fetch_prices_sequential(product_ids)
  internet = Async::HTTP::Internet.new
  product_ids.map do |id|
    response = internet.get("https://pricing.api/products/#{id}")
    JSON.parse(response.read)
  end
end

# Concurrent: all requests in-flight simultaneously using Async tasks
def fetch_prices_concurrent(product_ids)
  Async do
    internet = Async::HTTP::Internet.new
    tasks = product_ids.map do |id|
      Async do
        response = internet.get("https://pricing.api/products/#{id}")
        JSON.parse(response.read)
      end
    end
    tasks.map(&:wait)
  end.wait
end

# With timeout per task
def fetch_prices_with_timeout(product_ids, timeout: 5)
  Async do
    internet = Async::HTTP::Internet.new
    product_ids.map do |id|
      Async do
        Async::Task.current.with_timeout(timeout) do
          response = internet.get("https://pricing.api/products/#{id}")
          JSON.parse(response.read)
        end
      rescue Async::TimeoutError
        nil
      end
    end.map(&:wait)
  end.wait
end

The Async gem (Samuel Williams, 2018) uses Ruby Fibers to implement cooperative concurrency on top of the event loop. Unlike threads, Fibers yield control explicitly at I/O boundaries; the Async::HTTP client automatically yields when waiting for network responses.

Use Cases

  • 01API aggregation layers that call multiple downstream services and merge results; concurrent I/O reduces latency from sum of calls to max of calls
  • 02Web scrapers and bulk data fetchers that make many HTTP requests; async I/O allows hundreds of in-flight requests on one thread
  • 03Node.js web servers handling thousands of simultaneous connections where thread-per-connection model would exhaust memory
  • 04Real-time messaging servers (WebSocket, SSE) where thousands of clients maintain idle connections waiting for push notifications

When Not to Use

  • //Do not use async I/O for CPU-bound work; async I/O is effective when the bottleneck is network/disk wait time, not computation. CPU work blocks the event loop regardless of async syntax
  • //Do not nest too many awaits in series where parallelisation is possible; sequential async code does not improve on synchronous code in terms of throughput
  • //Do not assume async is always faster; for a single sequential database query, async adds overhead (Promise allocation, microtask scheduling) without parallelism benefit

Technical Notes

  • libuv (the C library underlying Node.js) uses epoll on Linux, kqueue on macOS/BSD, and IOCP on Windows for efficient I/O multiplexing. File system operations in Node.js are handled by a thread pool (default 4 threads, UV_THREADPOOL_SIZE env var controls size) because most OS file APIs are synchronous
  • The Node.js event loop has six phases: timers (setTimeout, setInterval), pending callbacks (I/O errors), idle/prepare, poll (I/O events, the main phase), check (setImmediate), close callbacks. process.nextTick callbacks run between every phase, before the next loop iteration
  • Ruby 3 introduced Fiber Scheduler interface (Bruno Sutic, 2021) allowing Gems to hook into the event loop; Async gem implements this interface. This means standard library IO (Net::HTTP, File) can be made non-blocking without modifying call sites
  • Go's goroutines are scheduled cooperatively by the Go runtime at function call boundaries and preemptively since Go 1.14. The runtime's scheduler (GOMAXPROCS threads) multiplexes thousands of goroutines onto a small number of OS threads, achieving M:N threading