Event demultiplexer
Start a timer, file read, and DNS lookup together, then observe ready callbacks returning to JavaScript.
Timeline
Understanding: Event demultiplexer
You can read this chapter before running the experiment. Then return to the live trace and match each concept to a real event.
Think of a hotel dispatcher. They do not stand at every door and repeatedly ask whether a room is ready. Services report readiness, and the dispatcher forwards those notifications to one receptionist: the JavaScript thread.
Node registers async work with libuv and returns control to the application. libuv uses OS readiness for sockets, usually delegates regular files and dns.lookup to its native thread pool, and tracks timers as time thresholds. These internal paths converge when callbacks return to JavaScript.
Terms used in this experiment
Understand the words first, then the execution order.
Event source
A readiness source such as a socket, timer, file operation, DNS request, or worker message.
Demultiplexer
A mechanism that waits for many sources and returns the subset that is ready.
libuv
The native Node library that unifies the Event Loop and async operations across operating systems.
Poll
The phase that handles many ready I/O callbacks and may wait for additional events.
What happens step by step
Each step maps to an observable runtime state.
- 01Registration
JavaScript starts a timer, readFile, and DNS without waiting in place.
- 02Delegation
libuv uses a timer structure, an OS facility, or its native thread pool.
- 03Free stack
Node can process other requests while the sources are not ready.
- 04Readiness signal
The OS/libuv reports which source has completed.
- 05Callback
The ready function enters the JavaScript stack when it is free.
Where the result needs context
These details explain why similar code can sometimes produce a different trace.
Not every source follows one path
A network socket usually uses epoll, kqueue, or IOCP; a regular file often uses the thread pool; a timer is a time-threshold check. “Demultiplexer” is a useful shared model, not one universal object behind every API.
dns.lookup differs from dns.resolve
dns.lookup uses the system resolver and usually the shared libuv pool. dns.resolve* methods issue DNS requests through another path. “DNS” alone does not identify the mechanism.
Promise.all starts nothing
readFile, lookup, and the timer start while the expressions above are evaluated. Promise.all receives already-created Promises, preserves result order, and coordinates waiting.
Fast rejection is not cancellation
On the success path Promise.all waits for all three results. If one Promise rejects, the aggregate rejects early, but the remaining operations are not automatically canceled.
First understand which parts of Node participate in execution.
Then remove instrumentation and focus on the central mechanism.
Finally match the model to the code that produces the live trace.
A minimal model without instrumentation
const file = fs.promises.readFile('package.json');
const dns = dns.promises.lookup('localhost');
const timer = new Promise(r => setTimeout(r, 180));
await Promise.all([file, dns, timer]);The complete code executed by the scenario
This is not an alternative example: these are the functions and files used by the Run button.
The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.
import { readFile } from 'node:fs';
import { lookup } from 'node:dns';
import { fileURLToPath } from 'node:url';
const packageJsonPath = fileURLToPath(
new URL('../package.json', import.meta.url),
);
async function eventDemultiplexer(emit) {
emit('call-stack', 'sync', 'Регистрируем три независимые операции');
const timerTask = new Promise((resolve) => {
emit('timers', 'schedule', 'Таймер 180 мс передан подсистеме таймеров');
setTimeout(() => {
emit('timers', 'callback', 'Таймер готов: callback вернулся в JavaScript');
resolve('timer');
}, 180);
});
const fileTask = new Promise((resolve, reject) => {
emit('libuv', 'schedule', 'Чтение package.json делегировано libuv');
readFile(packageJsonPath, 'utf8', (error, content) => {
if (error) {
reject(error);
return;
}
emit(
'poll',
'callback',
`Файл готов: получено ${Buffer.byteLength(content)} байт`,
);
resolve('file');
});
});
const dnsTask = new Promise((resolve, reject) => {
emit('libuv', 'schedule', 'DNS lookup localhost делегирован libuv');
lookup('localhost', (error, address) => {
if (error) {
reject(error);
return;
}
emit('poll', 'callback', `DNS готов: localhost → ${address}`);
resolve('dns');
});
});
emit(
'demultiplexer',
'info',
'JS-стек свободен; Event Loop ожидает сигналы готовности, а не опрашивает каждую функцию вручную',
);
const completed = await Promise.all([timerTask, fileTask, dnsTask]);
emit('result', 'result', `Все источники готовы: ${completed.join(', ')}`);
}The application instruments its own live trace: rows and timestamps are recorded by real emit(...) calls, while the scenario supplies source and lane labels. This is not a V8/libuv profiler or a direct view of their internal queues. await and Promise keep the HTTP stream open until the scenario completes.
How a learning mistake becomes an incident
A realistic service: the original code, observable failure, corrected implementation, and why the correction works.
An API aggregator waits for independent services sequentially
A product page combines catalog, inventory, and pricing data. The requests do not depend on one another, but the endpoint waits for each one in sequence and has no deadline.
Total latency becomes the sum of three I/O operations. One stalled upstream also keeps the HTTP request and its resources open indefinitely, even though Node can wait for all sources concurrently.
async function productPage(id) {
const product = await catalog.get(id);
const stock = await inventory.get(id);
const price = await pricing.get(id);
return { product, stock, price };
}Each following I/O operation is registered only after the previous one completes. With 120, 90, and 160 ms calls, the endpoint needs roughly 370 ms before its own overhead.
async function productPage(id) {
const signal = AbortSignal.timeout(800);
const [product, stock, price] = await Promise.all([
catalog.get(id, { signal }),
inventory.get(id, { signal }),
pricing.get(id, { signal }),
]);
return { product, stock, price };
}All independent operations are registered immediately, and their readiness notifications are demultiplexed back to JavaScript. A shared deadline limits how long resources are retained.
What the unfamiliar calls from both code samples actually do.
catalog.get(id)- A representative HTTP client or repository method: starts I/O and returns a Promise with an upstream response.
AbortSignal.timeout(800)- Creates a cancellation signal that fires after 800 ms. The client must support AbortSignal for it to cancel work.
Promise.all([...])- Subscribes to every input Promise immediately and fulfills when all finish; the first rejection rejects the aggregate.
[product, stock, price]- Result destructuring. Promise.all preserves input order, not the order in which the requests actually completed.
Common misconceptions
The myth is on the left; the accurate model is on the right.
Node repeatedly calls every function to ask whether it is ready.
Waiting is performed by efficient OS and libuv mechanisms.
Every async operation creates a new thread.
Sockets usually use OS readiness; only some APIs use a limited thread pool.
Parallel completion means parallel JavaScript callbacks.
Callbacks still enter a single JavaScript environment one at a time.
Promise.all starts and cancels its operations.
The operations are normally already running. Cancellation needs a separate mechanism such as AbortSignal when the specific API supports it.
Explain it in your own words
If you can explain the answer without quoting documentation, your mental model is starting to take shape.
- Why does a fast DNS lookup not have to wait for a slow timer?
- Which part differs between a network socket and a regular file?