Chapter 4
Asynchronous and Event-Driven Programming
Diploma in Full Stack Programming
Backend Development with Node.js/Express
•
Diploma
Asynchronous and Event-Driven Programming
Introduction
Backend applications rarely perform one operation at a time. A web server may receive an HTTP request, query a database, read a file, call an external service, and send a response while other clients are being served. If each operation stopped the JavaScript execution thread until completion, a small number of slow operations could make the entire server unresponsive.
Node.js addresses this problem through an asynchronous, event-driven architecture. JavaScript code normally runs on a single main thread, while the Node.js runtime and operating-system facilities coordinate input/output operations. Instead of waiting synchronously, an application registers a callback or continuation and allows other work to proceed. When the operation completes, the runtime schedules the associated function.
This model is powerful, but it does not make every operation non-blocking. CPU-intensive calculations, poorly designed loops, synchronous file operations, excessive microtasks, and unbounded memory use can still reduce performance. Professional Node.js development therefore requires an understanding of the event loop, asynchronous abstractions, binary data, streams, and concurrency hazards.
Learning Objectives
By the end of this chapter, you should be able to:
explain the event loop and distinguish JavaScript execution from operating-system and worker-pool activity;
write and evaluate callback-based, promise-based, and async/await code;
use events safely with EventEmitter;
identify blocking operations and reduce their effect on server responsiveness;
use Buffer objects and streams for binary and incremental data processing;
recognize race conditions and apply suitable synchronization or concurrency techniques;
design asynchronous Express handlers that handle success, failure, cancellation, and backpressure appropriately.
1. The Event-Driven Model
An event-driven program reacts to occurrences rather than continuously asking whether something has happened. An event may represent an incoming HTTP request, a completed file read, a timer expiration, a socket receiving data, or a stream becoming ready for more data.
Node.js combines three important ideas:
A JavaScript call stack. Synchronous JavaScript functions execute to completion on the main thread.
Asynchronous runtime services. The operating system or the libuv library performs or coordinates I/O outside the immediate JavaScript call stack.
A queue of callbacks and other continuations. Completed operations place work into queues that the event loop later processes.
The single-threaded JavaScript model simplifies many programs because two JavaScript statements do not execute simultaneously on the same thread. However, it does not eliminate concurrency. Several I/O operations can be in progress at the same time, and worker threads may execute selected tasks. Moreover, asynchronous callbacks can observe shared state at different times, producing race conditions.
The event loop
The event loop repeatedly checks whether work is ready and invokes its associated JavaScript callbacks. A simplified description of Node.js phases is:
Phase
Typical work
Timers
Callbacks for expired setTimeout and setInterval timers
Pending callbacks
Certain deferred system callbacks
Poll
Retrieve new I/O events and execute I/O callbacks
Check
Callbacks registered with setImmediate
Close callbacks
Events such as a socket or handle being closed
This is a conceptual model rather than a guarantee that every callback runs in a fixed visible order. Timer values are minimum delays, not exact execution times. A setTimeout(fn, 0) callback cannot execute until the current JavaScript work and relevant scheduling conditions permit it.
Promise reactions and queueMicrotask callbacks are microtasks. Node.js also provides process.nextTick, which has its own high-priority queue. These queues are processed between suitable pieces of event-loop work. Excessive use of process.nextTick or recursively scheduled microtasks can starve I/O by preventing the loop from reaching later phases.
Figure: A simplified view of how asynchronous work returns to the JavaScript event loop.
Blocking and non-blocking operations
A blocking operation prevents the current JavaScript thread from processing other work. Examples include fs.readFileSync, crypto.pbkdf2Sync, large regular-expression computations, and a loop that performs billions of iterations. Non-blocking APIs return control before the operation has completed, such as fs.promises.readFile or a network request made with an asynchronous client.
“Non-blocking” does not mean “instantaneous.” It means that the caller is not forced to wait synchronously. An asynchronous operation can still consume CPU, memory, file descriptors, worker-pool capacity, or network bandwidth. Large numbers of concurrent operations may therefore create resource contention.
The libuv worker pool is used by some filesystem, DNS, cryptographic, and compression operations. Its size is limited; increasing it can help particular workloads but may also increase CPU and memory contention. Network sockets commonly rely on operating-system readiness notifications rather than occupying one JavaScript thread per connection.
2. Callbacks, Promises, and async/await
Callbacks
A callback is a function supplied to be invoked later. The conventional Node.js error-first form places the error argument first and the successful result second:
The callback must handle both success and failure. A common historical problem is “callback hell”: deeply nested callbacks become difficult to read, test, and maintain. Other risks include invoking a callback twice, forgetting to return after an error, and allowing exceptions to escape unpredictably from asynchronous code.
Promises
A Promise represents the eventual completion or failure of an asynchronous operation. It has the states pending, fulfilled, or rejected. A settled promise cannot change state again.
then returns another promise, allowing operations to be composed. A rejection should be handled with catch or by an enclosing try...catch when using await. Promise combinators express different concurrency policies:
Method
Behaviour
Promise.all
Runs tasks concurrently; rejects when the first input rejects
Promise.allSettled
Waits for every task and reports each result
Promise.race
Settles when the first input settles
Promise.any
Fulfils when the first input fulfils; rejects if all reject
Promise.all is appropriate when all results are required and independent. It does not cancel the remaining operations after one rejects. If cancellation matters, use an API supporting AbortSignal and abort explicitly.
async/await
An async function always returns a promise. await suspends that function’s continuation until its operand settles; it does not block the entire JavaScript thread.
async function loadConfig() {
try {
const text = await fs.readFile('config.json', 'utf8');
return JSON.parse(text);
} catch (error) {
throw new Error('Invalid configuration', { cause: error });
}
}
Independent operations should not be awaited unnecessarily in sequence:
The handler should send exactly one response. Errors after headers have been sent require special treatment, and production applications should use centralized error middleware, structured logging, and request identifiers.
3. Events and EventEmitter
Node’s EventEmitter implements a publish–subscribe pattern. An emitter stores listeners under event names and calls them when emit is invoked.
Listeners registered with on remain active. once removes itself after the first invocation. An emitted 'error' event requires an error listener; otherwise Node may terminate the process. Listener management is important because an emitter retaining listeners can retain objects and cause memory leaks.
Events are useful for notifications, but they are not automatically durable. If no listener is present when an event is emitted, the event is normally lost. Events should not replace a database or message broker when delivery, persistence, ordering, retries, or auditability is required. Event listeners should also avoid long synchronous work, because listeners execute on the JavaScript thread.
4. Buffers and Binary Data
A Buffer is a Node.js representation of a fixed-length sequence of bytes. It is a subclass-like form of Uint8Array designed for binary data such as files, TCP packets, compressed content, and cryptographic material.
Encodings matter. UTF-8 uses a variable number of bytes per character, so the number of characters need not equal buffer.length. Never convert arbitrary binary data to a string unless the format specifies an encoding. Base64 is useful for transport in text-only contexts, but it increases representation size and should not be used unnecessarily.
Buffers are mutable. A slice may share memory with the original buffer, so code that requires isolation should use Buffer.from to copy data. Validate lengths and formats when processing untrusted binary input. Avoid unsafe allocation patterns and do not expose sensitive buffers longer than necessary.
5. Streams and Backpressure
Streams process data incrementally rather than loading an entire resource into memory. Node provides readable, writable, duplex, and transform streams.
A readable stream supplies data.
A writable stream accepts data.
A duplex stream is both readable and writable.
A transform stream modifies data as it passes through.
pipeline is generally preferable to manually connecting streams because it propagates errors and closes the pipeline appropriately. A readable stream emits chunks, which are commonly Buffers. A text decoder or setEncoding('utf8') should be used when character boundaries must be handled correctly across chunks.
Backpressure occurs when a destination can accept data more slowly than a source produces it. Ignoring backpressure can cause unbounded buffering and memory exhaustion. With a writable stream, write() returns false when the producer should pause; the producer should resume after the 'drain' event. pipeline manages this coordination for connected streams.
Streams are particularly valuable for large uploads, downloads, media files, compression, database exports, and proxy services. They reduce peak memory use, but they do not remove the need for limits, timeouts, authentication, and input validation.
6. Race Conditions and Concurrency Control
A race condition occurs when the outcome depends on the timing or interleaving of operations. Node’s single JavaScript thread does not prevent races across asynchronous boundaries.
Two requests can both read the same stock value before either saves. Both then write a decremented value, allowing two sales to be recorded as one deduction. The issue is a non-atomic read–modify–write sequence.
Suitable solutions include:
an atomic database update such as “decrement where stock > 0”;
a transaction with an appropriate isolation level;
optimistic locking using a version number and conditional update;
a distributed lock when ownership and failure handling are carefully designed;
serialization through a queue when ordered processing is acceptable;
idempotency keys to make retries safe.
A mutex or in-process queue can protect data within one Node process, but it does not protect against another process, container, or server instance. Shared state should therefore normally be guarded at the shared resource itself, such as a database or distributed coordination service.
Concurrency also requires limiting work. A program that starts thousands of expensive operations simultaneously may overload a database or external API. A semaphore or concurrency-limited queue can allow only a defined number of tasks at once. The correct limit depends on service capacity, latency, memory, and failure behaviour; it should be measured rather than guessed.
7. Practical Design Patterns
Timeouts and cancellation
Every external operation should have a bounded lifetime where possible. A timeout prevents a stalled dependency from consuming resources indefinitely. AbortController and AbortSignal provide a standard cancellation mechanism for many modern APIs:
Cancellation is cooperative. A signal does not magically stop arbitrary JavaScript or a database query that does not support cancellation.
Error, retry, and idempotency policy
Retries should be limited, delayed, and applied only to failures likely to be temporary. Exponential backoff with jitter reduces synchronized retry storms. A non-idempotent operation, such as charging a payment card, must use an idempotency key or provider-supported transaction mechanism before it is retried.
Measuring responsiveness
Monitor event-loop delay, request latency, throughput, error rates, memory, CPU, and external dependency latency. Profilers and event-loop monitoring can reveal synchronous hot spots. Logging should include correlation identifiers but must not expose passwords, tokens, or private personal data.
Applied Case: Illustrative Order-Import Service
Background and problem
An online retailer receives a large CSV file of orders. An initial implementation reads the complete file with readFile, parses every row in memory, checks inventory, and inserts orders. During large imports, normal API requests become slow and memory usage rises sharply. Duplicate orders also appear when the client retries an import after a timeout.
Analysis
The complete-file read creates a memory peak proportional to file size. Parsing and validation occur in long synchronous sections, blocking the event loop. Inventory checking and order insertion are separate operations, creating a race condition. Finally, the import endpoint has no idempotency policy.
Approach and solution
The service is redesigned as follows:
Accept the upload as a readable stream and impose a maximum request size.
Parse rows incrementally with a transform stream rather than creating one large string.
Limit concurrent validation requests with a bounded worker queue.
Use a database transaction or atomic inventory update for each order.
Store an import identifier and row-level idempotency key.
Use pipeline so parsing, validation, and output errors are propagated.
Return an accepted job identifier and process lengthy work asynchronously.
Report progress through a durable job-status record rather than relying only on transient events.
Apply timeouts and bounded retries to external services.
Outcome
Peak memory becomes largely independent of total file size, subject to configured stream buffers and concurrent work. The API remains responsive because parsing is incremental and CPU-heavy work is bounded or moved to worker processes when necessary. Atomic inventory operations prevent overselling, while idempotency prevents retries from creating duplicate orders.
Learning points
The case demonstrates that asynchronous design is broader than replacing callbacks with await. Correct design combines non-blocking execution, streaming, backpressure, bounded concurrency, data consistency, cancellation, and operational observability.
Limitations, Security, and Governance
Asynchronous systems are harder to reason about because temporal order is less visible. Failures may occur after a request has returned, logs may be interleaved, and retries can duplicate side effects. Tests should cover delayed responses, rejection paths, cancellation, partial writes, duplicate messages, and resource exhaustion.
Security controls include request-size limits, stream timeouts, safe parsing, protection against denial-of-service through expensive input, and careful handling of buffers containing credentials or personal data. Do not use eval for untrusted content. Validate file types by trusted format inspection rather than filename alone.
Privacy and governance requirements may affect event retention, audit logs, data deletion, and cross-service transfer. Durable events and job records should have defined retention periods and access controls. Production systems should also define who may replay jobs and how duplicate side effects are prevented.
Worker threads and child processes are appropriate when CPU-bound tasks would block the event loop. Worker threads can share memory through specialized mechanisms, while child processes provide stronger isolation at greater communication cost. Neither should be introduced without measuring the bottleneck and defining failure recovery.
Best Practices Checklist
Prefer asynchronous APIs in request-handling paths.
Keep synchronous CPU work small; move substantial CPU work to workers or separate services.
Handle every asynchronous rejection and every relevant stream error.
Use Promise.all only for independent operations whose combined load is acceptable.
Apply timeouts, cancellation, bounded retries, and concurrency limits.
Use pipeline for connected streams and respect backpressure.
Treat shared-resource updates as transactions or atomic operations.
Make retried operations idempotent where possible.
Set listener lifetimes deliberately and monitor for leaks.
Test adverse timing, not only the successful fast path.
Chapter Summary
Node.js asynchronous programming is based on a JavaScript execution thread coordinated with runtime I/O services, worker-pool tasks, queues, and the event loop. Callbacks, promises, and async/await provide progressively more composable ways to represent future results. The event-driven model improves responsiveness only when synchronous work and resource usage remain bounded.
EventEmitter supports in-process notifications but does not provide durable delivery. Buffers represent binary bytes, while streams process data incrementally. Backpressure is essential when producers and consumers operate at different speeds. Race conditions can occur across await boundaries and must be addressed with atomic database operations, transactions, version checks, queues, or carefully scoped locks. Professional systems also require timeouts, cancellation, idempotency, observability, security controls, and deliberate concurrency limits.
Key Terms
Event loop: The runtime mechanism that schedules ready asynchronous callbacks and other JavaScript work.
Callback: A function invoked after an operation completes or an event occurs.
Promise: An object representing the eventual fulfilment or rejection of an asynchronous operation.
async function: A function that returns a promise and may use await.
Microtask: High-priority continuation work such as a promise reaction or queueMicrotask callback.
Blocking operation: Work that prevents the JavaScript thread from processing other callbacks.
EventEmitter: Node.js class for registering listeners and publishing named events.
Buffer: A fixed-length sequence of bytes used for binary data.
Stream: An abstraction for reading, writing, or transforming data incrementally.
Backpressure: Flow-control behaviour that slows a producer when a consumer cannot keep up.
Pipeline: A connected sequence of streams with coordinated completion and error handling.
Race condition: A timing-dependent error caused by unsafe interleaving of operations.
Atomic operation: An operation that completes as one indivisible state change.
Idempotency: Property whereby repeating an operation produces the same intended effect as performing it once.
AbortSignal: A signal used to communicate cancellation to supporting asynchronous APIs.
Worker pool: Runtime-managed threads used by selected Node.js operations.
Concurrency limit: A bound on the number of tasks permitted to run simultaneously.
Event-loop starvation: Failure to process I/O promptly because JavaScript or microtasks continually occupy execution time.
Review Questions
Short Answer Questions
What is the difference between blocking and non-blocking execution in Node.js?
Why is process.nextTick capable of starving I/O?
What is the purpose of backpressure in a stream pipeline?
Why does Promise.all not automatically cancel its remaining operations after one rejection?
Give two ways to prevent a race condition when updating shared inventory.
Long Answer Questions
Explain the main event-loop phases and the roles of the JavaScript stack, operating-system I/O, and the worker pool.
Compare callbacks, promises, and async/await, including error-handling and composition considerations.
Explain how Buffers and streams support efficient processing of large binary resources.
Discuss why EventEmitter is useful for in-process notifications but insufficient for durable business events.
Design an asynchronous Express endpoint that calls two independent services and explain its timeout, error, cancellation, and concurrency policies.
Analytical / Application Questions
An endpoint reads a 2 GB upload with readFile, converts it to a string, and then compresses it synchronously. Identify the blocking and memory risks and propose a stream-based redesign.
Two checkout requests both observe one remaining unit of stock and both create successful orders. Explain the race condition and propose a database-level correction and an application-level retry policy.
A service starts 10,000 external API requests with Promise.all, causing rate limits and memory pressure. Design a bounded-concurrency approach and explain how you would choose and monitor the concurrency limit.
Multiple Choice Questions with Answers
Question 1
Which statement best describes the Node.js event loop?
A. It creates one JavaScript thread for every HTTP request.
B. It schedules ready asynchronous work for execution on the JavaScript thread.
C. It guarantees that every timer runs exactly at its requested delay.
D. It permanently moves all JavaScript execution to the worker pool.
Correct Answer: B. It schedules ready asynchronous work for execution on the JavaScript thread.
Question 2
What does an async function return?
A. A Buffer
B. An EventEmitter
C. A Promise
D. A writable stream
Correct Answer: C. A Promise
Question 3
Which Promise method waits for all input operations and reports both fulfilments and rejections?
A. Promise.race
B. Promise.any
C. Promise.all
D. Promise.allSettled
Correct Answer: D. Promise.allSettled
Question 4
What does a writable stream returning false from write() usually indicate?
A. The data is invalid JSON.
B. The producer should respect backpressure and wait before writing more.
C. The stream has permanently closed.
D. The operation has completed successfully.
Correct Answer: B. The producer should respect backpressure and wait before writing more.
Question 5
Why can a large synchronous loop harm a Node.js server?
A. It prevents the JavaScript thread from processing other callbacks.
B. It automatically deletes the event queue.
C. It converts all strings into Buffers.
D. It forces every socket to use a separate process.
Correct Answer: A. It prevents the JavaScript thread from processing other callbacks.
Question 6
Which technique most directly prevents a lost-update inventory race at the shared database?
A. Adding more EventEmitter listeners
B. Using an atomic conditional update or transaction
C. Calling setTimeout before every save
D. Converting the inventory value to a string
Correct Answer: B. Using an atomic conditional update or transaction
Question 7
What is a Buffer primarily used to represent?
A. A sequence of binary bytes
B. A list of event listeners
C. A timer schedule
D. A database transaction
Correct Answer: A. A sequence of binary bytes
Question 8
Which API is generally preferred for connecting streams with coordinated error handling?
A. JSON.stringify
B. process.nextTick
C. stream.pipeline
D. Promise.race
Correct Answer: C. stream.pipeline
Question 9
What is a key limitation of an in-process EventEmitter for business-critical notifications?
A. It cannot call synchronous listeners.
B. Events are normally not durable if no listener is present.
C. It can transmit only numbers.
D. It always runs listeners in worker threads.
Correct Answer: B. Events are normally not durable if no listener is present.
Question 10
Why should an external operation often have a timeout?
A. To guarantee that the remote service succeeds
B. To prevent a stalled operation from consuming resources indefinitely
C. To make all retries safe automatically
D. To eliminate the need for error handling
Correct Answer: B. To prevent a stalled operation from consuming resources indefinitely
Backend Development with Node.js/Express
Chapter 4
of 15