Synchronous vs Asynchronous — Lecture Breakdown


1. THE GIST

Sync vs async boils down to one question: can you do other work while waiting for something to finish? Synchronous means you block until you get an answer — no other work happens. Asynchronous means you fire a request, move on, and get notified when it's done. This isn't just a Node.js concept — it shows up at every layer of the stack: OS I/O, database commits, replication, file writes, and network calls. Node.js looks async but under the hood uses worker threads and OS-level primitives like epoll to fake it on blocking operations. The async pattern is almost always preferable because it wastes less CPU time waiting and lets the system do more with the same resources.


2. THE BREAKDOWN

The Core Mental Model

  • Synchronous — caller and receiver execute in lockstep. Caller blocks, does nothing, waits. Like asking someone a question in a meeting — awkward silence until they reply.
  • Asynchronous — caller fires the request and moves on. Like sending an email — you don't sit staring at your screen waiting for a reply.

The key word is block. Sync = caller is blocked. Async = caller is not blocked.

Synchronous I/O — What Actually Happens at the OS Level

  1. Program calls read() on a file or socket
  2. OS takes the program's thread off the CPU (context switch) — you're not executing, so why hog the processor?
  3. OS sends the request down to the device driver → disk controller → SSD/HDD
  4. Device does the read, signals the kernel
  5. Kernel puts your thread back on the CPU
  6. Program resumes

This looks "free" but context switching costs microseconds. Do it millions of times and it adds up. And during the wait, your thread is doing absolutely nothing — wasted.

Asynchronous I/O — The Two Approaches

Readiness-based (polling):

  • epoll (Linux), kqueue (macOS) — "tell me when these file descriptors have data ready to read"
  • Node.js uses epoll on Linux
  • You get notified that data is ready, then you read it — two steps

Completion-based:

  • io_uring (Linux, newer), IOCP (Windows) — "here's the buffer, fill it; tell me when it's done"
  • OS does the read for you, writes result to a completion queue
  • You just check the queue — one step, zero blocking

Node.js's trick for non-async-able operations: Some OS operations (e.g., file reads on Linux) can't use epoll. Node.js's workaround: spin up a worker thread from its thread pool (default: 4 threads) and let that thread block. Main event loop stays free. From your perspective as a dev? It's async. Under the hood? A thread is blocking so the main thread doesn't have to.

Async Callback Styles in Node.js (Evolution)

Callbacks → Promises → async/await

All three are async. async/await is syntactic sugar over Promises — it looks synchronous (sequential code) but the event loop is still free to do other work while await-ing. This is the critical distinction: await pauses your function, not the process.

Async Backend Processing — The Queue Pattern

When a long-running job arrives:

Synchronous approach:

Client → Request → Backend processes for 30s → Response
Client blocked for 30s

Async approach:

Client → Request → Backend queues job → Immediately responds with jobId
Client free immediately
Backend worker processes job in background
Client checks back later (polling / SSE / pub-sub)

This is the most important real-world application of async in backend engineering. Job queues (BullMQ, SQS, Celery) all implement this.

Async at the Database Layer — Three Places

1. Async Commits (Postgres)

  • Default (sync): COMMIT blocks until the WAL (Write-Ahead Log) is flushed to disk
  • Async commit: returns success immediately, flushes to disk in the background
  • Risk: crash between "success" response and actual disk write = data loss
  • Use for: high-throughput non-critical transactions where losing last few ms of data is acceptable

2. Async Replication

  • Sync replication: primary waits for majority of replicas to acknowledge before returning COMMIT — strong consistency, higher latency
  • Async replication: primary commits, streams changes to replicas independently — low latency, eventual consistency, replicas may lag

3. OS fsync / Page Cache

  • By default, OS buffers writes in memory (page cache) and flushes to disk asynchronously in batches
  • Databases bypass this with fsync() — go directly to disk, skip the cache
  • Why? Data integrity — can't trust OS cache to flush before a crash
  • Cost: slower writes. Trade-off: durability vs throughput

3. ENGINEER'S NOTEBOOK

Core idea: Sync = blocked, waiting, doing nothing. Async = fire and move on, get notified later. Async is almost always what you want because wasted wait time is wasted CPU and wasted capacity.

Key moving parts:

  • Blocking — thread/process can't execute anything else while waiting
  • Context switch — OS swaps a blocked thread off the CPU and gives time to another; costs ~microseconds, adds up
  • Event loop — Node.js's mechanism for checking: any I/O done? Any timers? Any callbacks to call? Runs continuously on the main thread
  • Thread pool — Node.js's 4 (default) worker threads that handle operations epoll can't (file reads, DNS, crypto)
  • epoll — Linux kernel interface: "watch these file descriptors and tell me when any are ready to read/write"
  • io_uring — newer Linux async I/O: completion-based, lower overhead than epoll, increasingly adopted
  • WAL (Write-Ahead Log) — Postgres's durability journal; COMMIT flushes this to disk before returning
  • fsync — system call that forces OS to flush cached writes to physical disk, bypassing page cache

Gotchas:

  • async/await ≠ parallel — await pauses your function but not other concurrent requests. Two awaits in sequence are still sequential. Use Promise.all() for parallel async work
  • CPU-bound ≠ I/O-bound — async helps with I/O-bound work (waiting on disk, network, DB). If you're doing heavy computation, async doesn't help — you're burning CPU, not waiting. Use worker threads or separate processes for CPU-bound work
  • Node.js thread pool exhaustion — if you have more concurrent blocking file operations than thread pool size (4 by default), extra operations queue. Under heavy load, increase via UV_THREADPOOL_SIZE env var (max = CPU count for CPU-bound, higher OK for I/O-bound)
  • Async commit data loss — async Postgres commits can lose the last ~1-2 commits on crash. Never use for financial transactions, authentication events, or anything where losing a write is unacceptable
  • Async replication lag — replica reads may return stale data. If you write then immediately read from a replica, you might not see your own write. Design read-after-write paths to hit the primary or use sync replication for critical reads
  • OS page cache is not your friend for durability — writing a file and calling it "saved" without fsync is a lie. The data is in RAM. Power failure = data loss. Databases know this; general application code usually doesn't

Terms worth knowing:

  • Synchronous I/O — blocking call; thread waits, does nothing until result is returned
  • Asynchronous I/O — non-blocking; thread continues executing, result delivered via callback/event/completion queue
  • epoll — Linux readiness-based async I/O; Node.js's primary mechanism for network sockets
  • io_uring — Linux completion-based async I/O; more efficient than epoll, increasingly used in modern runtimes
  • Event Loop — Node.js's central loop checking for completed I/O, timers, and callbacks to execute
  • Thread Pool — Node.js's fallback for blocking operations that can't use epoll (libuv maintains this)
  • WAL — Write-Ahead Log; Postgres's durability guarantee — changes logged here before being applied to pages
  • fsync — forces kernel to flush page cache writes to physical disk; used by databases for durability
  • Async Commit — DB returns success before confirming disk write; faster, less durable
  • Async Replication — primary doesn't wait for replicas to confirm; faster commits, eventual consistency on replicas

Don't forget this:

  • async/await looks sync but isn't — the event loop is still free during await
  • Node.js has a 4-thread pool for blocking ops — know this when debugging mysterious slowdowns under load
  • Async commit in Postgres = you can lose data on crash — never for critical writes
  • OS page cache buffers writes — fsync is what databases use to actually guarantee durability
  • Async = better throughput; Sync = simpler, easier to reason about. Pick based on what you need, not what's trendy

4. QUICK REFERENCE CARD

SYNC vs ASYNC — QUICK REF
─────────────────────────────────────────────────
Sync:  caller blocks, waits, does nothing until response
Async: caller fires request, moves on, gets callback/event later

Real-world analogy:
  Sync  → asking question in a meeting (must wait for answer)
  Async → sending an email (send and move on)

Node.js async mechanism:
  Network sockets  → epoll (readiness-based)
  File reads       → thread pool (libuv, default 4 threads)
  Default threads  → UV_THREADPOOL_SIZE env var (max useful ≈ CPU count for CPU-bound)

async/await rules:
  await pauses YOUR function, not the process
  sequential awaits = sequential execution (not parallel)
  parallel: await Promise.all([a(), b(), c()])

Async backend processing pattern:
  Client → POST /job → immediate jobId response → client polls/subscribes
  Backend → worker picks up job from queue → processes → stores result

Postgres async options:
  Async commit       → fast, risk losing last 1-2 commits on crash
  Async replication  → low latency, replicas may lag behind primary
  OS page cache      → default buffered writes (fsync bypasses this)

When async DOESN'T help:
  CPU-bound work (computation) → use worker threads / separate process
  Async only helps I/O-bound work (waiting on disk/network/DB)
─────────────────────────────────────────────────

5. THE "AHA" MOMENT

The Kitchen During Dinner Rush

A synchronous chef does one thing at a time. They put the steak on the grill, then stand there staring at it for 8 minutes until it's done. Then they boil the pasta — stand there, watch it boil. Then they make the sauce. Every task blocks every other task. The restaurant serves 3 tables a night.

An asynchronous chef works completely differently. They put the steak on the grill, set a timer, and immediately start boiling the pasta. While the pasta boils, they prep the sauce. The timer goes off — steak is done — they plate it. The pasta is done — they plate that too. Same chef, same kitchen, way more throughput.

Node.js is the async chef. The "timers" are the event loop. The "grill" and "pasta pot" are I/O operations happening in the background (handled by the OS or thread pool). The chef never stands around waiting — they check what's ready and act on it.

But here's the twist engineers miss: the async chef still has only two hands (the thread pool). If you ask Node.js to read 20 files simultaneously and the thread pool has 4 threads, 16 of them queue. The kitchen has limited burners. Knowing this is why you can diagnose "Node.js is slow under load" — it's not the event loop, it's thread pool exhaustion.

The Postgres async commit is the chef yelling "order up!" to the waiter before they've actually finished plating. The customer is unblocked. The chef finishes plating in the background. 99.9% of the time this is fine. But if the kitchen catches fire right after "order up!" — the plate never gets finished and the customer got lied to.


6. SHOW ME THE CODE

// ─────────────────────────────────────────────────────
// SYNC vs ASYNC — Node.js deep dive
// Shows: blocking vs non-blocking, event loop behavior,
// parallel async, and the async job queue pattern
// ─────────────────────────────────────────────────────

const fs = require("fs");
const fsPromises = require("fs/promises");

// ══ PART 1: Sync vs Async file read ══════════════════

function demonstrateSyncVsAsync() {
  console.log("\n── SYNC FILE READ ──");
  console.log("1. Before sync read");

  // This BLOCKS the entire Node.js process
  // WHY THIS IS BAD: during this read, no other requests can be handled,
  // no timers fire, no callbacks execute — event loop is frozen
  const data = fs.readFileSync("./test.txt", "utf8");

  console.log("2. File content:", data.trim());
  console.log("3. After sync read");
  // Output order: 1 → 2 → 3 (sequential, predictable, but blocking)


  console.log("\n── ASYNC FILE READ (callback style) ──");
  console.log("A. Before async read");

  // This schedules the read and IMMEDIATELY returns
  // WHY: Node.js hands this to a libuv thread pool worker and moves on
  fs.readFile("./test.txt", "utf8", (err, data) => {
    if (err) throw err;
    // This runs LATER when the worker thread finishes
    console.log("C. File content:", data.trim());
  });

  console.log("B. After async read (file still being read!)");
  // Output order: A → B → C  ← B runs before C because we didn't block
}

// ══ PART 2: async/await — looks sync, isn't ══════════

async function demonstrateAsyncAwait() {
  console.log("\n── ASYNC/AWAIT ──");
  console.log("1. Starting");

  // await pauses THIS function but NOT the event loop
  // WHY: other concurrent requests/callbacks still execute during this pause
  const data = await fsPromises.readFile("./test.txt", "utf8");
  console.log("2. Got file:", data.trim());

  // SEQUENTIAL awaits — common mistake
  console.log("\n── Sequential (slow) ──");
  const start = Date.now();
  await delay(100); // simulating DB query 1
  await delay(100); // simulating DB query 2
  await delay(100); // simulating DB query 3
  console.log(`Sequential: ${Date.now() - start}ms`); // ~300ms

  // PARALLEL awaits — correct approach when operations are independent
  console.log("\n── Parallel (fast) ──");
  const start2 = Date.now();
  await Promise.all([delay(100), delay(100), delay(100)]);
  console.log(`Parallel: ${Date.now() - start2}ms`); // ~100ms
  // WHY: all three fire simultaneously; we wait for the slowest one
}

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));


// ══ PART 3: Async Backend Job Queue Pattern ═══════════
// The real-world pattern: submit job → get ID → poll for result
// This is how YouTube uploads, Stripe payouts, AWS transcoding work

const express = require("express");
const { v4: uuidv4 } = require("uuid");
const app = express();
app.use(express.json());

const jobStore = new Map(); // jobId → { status, result, createdAt }
const jobQueue = []; // Simple in-memory queue; use BullMQ/SQS in prod

// Step 1: Client submits work — returns IMMEDIATELY with jobId
// WHY: don't make the client wait for potentially long-running work
app.post("/jobs", (req, res) => {
  const { input } = req.body;
  const jobId = uuidv4();

  // Store initial state
  jobStore.set(jobId, {
    status: "queued",
    result: null,
    createdAt: Date.now()
  });

  // Push to queue — worker will pick this up asynchronously
  jobQueue.push({ jobId, input });

  console.log(`[Job ${jobId.slice(0, 8)}] Queued`);

  // Client is IMMEDIATELY unblocked — no waiting for processing
  // This is the key architectural decision that makes this async
  res.status(202).json({
    jobId,
    status: "queued",
    checkUrl: `/jobs/${jobId}` // tell client where to poll
  });
});

// Step 2: Client polls for result — pure request-response
app.get("/jobs/:jobId", (req, res) => {
  const job = jobStore.get(req.params.jobId);
  if (!job) return res.status(404).json({ error: "Job not found" });
  res.json({ jobId: req.params.jobId, ...job });
});

// Background worker — processes jobs from the queue asynchronously
// In prod: this is a separate process/service consuming from SQS/BullMQ
async function processJobQueue() {
  while (true) {
    if (jobQueue.length > 0) {
      const { jobId, input } = jobQueue.shift();
      const job = jobStore.get(jobId);

      job.status = "processing";
      console.log(`[Job ${jobId.slice(0, 8)}] Processing...`);

      try {
        // Simulate long-running work (video encoding, ML inference, etc.)
        await delay(3000);
        const result = `Processed: ${input} → ${input.toUpperCase()}`;

        job.status = "complete";
        job.result = result;
        job.completedAt = Date.now();
        console.log(`[Job ${jobId.slice(0, 8)}] Complete`);

      } catch (err) {
        job.status = "failed";
        job.error = err.message;
      }
    }

    // Yield to event loop between checks — CRITICAL
    // WHY: without this, this while(true) would starve the event loop
    // Other requests would never get handled
    await delay(100);
  }
}

app.listen(3000, () => {
  console.log("Server on :3000");
  processJobQueue(); // start background worker
});

// ─────────────────────────────────────────────────────
// TEST THE JOB QUEUE:
//
// 1. Submit job (returns immediately):
//    curl -X POST http://localhost:3000/jobs \
//      -H "Content-Type: application/json" \
//      -d '{"input":"hello world"}'
//    → { "jobId": "abc-123", "status": "queued" }
//
// 2. Poll while processing:
//    curl http://localhost:3000/jobs/abc-123
//    → { "status": "processing" }
//
// 3. Poll after 3s:
//    curl http://localhost:3000/jobs/abc-123
//    → { "status": "complete", "result": "Processed: hello world → HELLO WORLD" }
//
// KEY: Step 1 returns in <5ms regardless of job duration.
// The client was NEVER blocked waiting for the 3 second work.
// ─────────────────────────────────────────────────────

7. WHERE YOU'LL SEE THIS IN THE WILD

Node.js Event Loop in Production — Every Express/Fastify server you've ever run is the event loop in action. One thread handling thousands of concurrent connections — possible only because network I/O is async via epoll. The moment you put a readFileSync or a CPU-heavy computation in a request handler, you've frozen the event loop and every other user's request queues behind it. This is the single most common Node.js performance bug.

BullMQ / Celery / SQS — Async Job Queues — The pattern from Part 3 of the code, at production scale. Stripe uses async job queues for payment processing. Airbnb for image processing. GitHub for CI builds. The pattern is always: accept request instantly → queue work → worker processes independently → client polls or gets notified. The queue is the buffer between synchronous client expectations and asynchronous processing reality.

Postgres synchronous_commit = off — A real production knob that teams flip when write throughput matters more than zero-data-loss guarantees. Analytics pipelines, logging systems, and high-frequency event stores often use this. Financial transaction tables don't.

Database Read Replicas and Replication Lag — Every "read from replica, write to primary" architecture is betting on async replication lag being acceptable. Usually it is (milliseconds). But if you write a user's profile update and immediately redirect them to their profile page, reading from a lagging replica returns the old data. The fix: read-your-own-writes routing (send the user to primary for their own data, replicas for everyone else's).

Redis — Async Persistence — Redis's RDB snapshots and AOF (Append-Only File) logs are both async by default. Redis can lose the last few seconds of writes on a crash. fsync always in AOF mode gives you durability at the cost of write throughput — the same sync/async trade-off the lecture covers, applied to the most popular cache/queue in the industry.

AWS Lambda Cold Starts — Lambda's async execution model is the job queue pattern in managed form. You submit a function invocation — AWS queues it, a worker picks it up, executes, stores the result. The InvocationType: Event parameter is literally async mode: fire-and-forget, no waiting for the result. InvocationType: RequestResponse is the sync version — you wait.

io_uring in Modern Databases — RocksDB, ScyllaDB, and other high-performance storage engines are migrating I/O to io_uring to eliminate the overhead of the old epoll/thread-pool approach. Completion-based async at the kernel level — the cutting edge of what the lecture's async OS section points toward.


8. EXPLAIN IT TO MY NON-TECH FRIEND

Imagine you're at a coffee shop, but you order at the counter and then have to stand there blocking the entire line until your coffee is made. Nobody else can order. Nobody else gets served. The whole shop grinds to a halt waiting for your oat milk latte. That's synchronous — one thing at a time, everyone waits.

Now imagine how a real coffee shop works. You order, the barista hands you a buzzer and your receipt, and you go sit down. The next person orders immediately. The one after them orders. The barista is making everyone's drinks in parallel. Your buzzer goes off — you pick up your coffee. That's asynchronous — work is happening in the background, you're not standing there blocking the whole operation.

Your computer runs the same way. When old programs needed to read a file, they'd stand at the counter and wait — frozen, unresponsive, doing nothing. That's why apps used to lock up. Modern programs say "read this file and buzz me when it's done" and then keep running — responding to clicks, handling other requests, doing other work.

The job queue pattern is like ordering catering for a big event. You call the restaurant, they take your order, and immediately say "confirmed, order number 4821." You hang up and get on with your day. They cook everything over the next few hours. You call back later: "Is order 4821 ready?" "Yes, picking it up in an hour." You were never on hold for three hours while they cooked. That's the difference between synchronous and asynchronous backend processing — and it's why YouTube can say "your video is uploading" and let you close the tab.