Node.js as a backend runtime: event loop, I/O and built-in tests
What happens under async/await, why sync I/O blocks every client, and how to build a non-blocking file store with node:test tests.
Lesson content is currently available in Russian.
What you will learn
- →Объяснять роль event loop и отличать конкурентный I/O от параллельного CPU-кода.
- →Выбирать Promise API файловой системы и не блокировать обработку других запросов.
- →Управлять конфигурацией и завершением процесса без скрытых глобальных зависимостей.
- →Проверять Task Store встроенными node:test и assert.
Event loop: один JavaScript-поток не означает одну задачу
Node.js выполняет JavaScript callback в основном потоке, а ожидание многих операций I/O делегирует системе. Пока чтение файла или сеть ждут, event loop может обработать другую готовую работу. Это конкурентность, но не автоматический параллелизм любой функции: тяжёлый синхронный цикл всё ещё удержит поток и задержит все запросы.
import { setTimeout as delay } from "node:timers/promises";
async function job(name, ms) {
await delay(ms);
return name;
}
console.time("jobs");
console.log(await Promise.all([job("a", 100), job("b", 100)]));
console.timeEnd("jobs"); // около 100 мс, а не 200
Promise.all не создаёт потоки для JavaScript. Он лишь позволяет независимо начатым операциям ожидания перекрываться. CPU-тяжёлую работу нужно дробить, выносить в worker_threads или отдельный сервис после измерений. Частая ошибка — считать async ключевым словом, которое само делает функцию неблокирующей: синхронный цикл внутри async всё равно блокирует.
Файловое хранилище без sync I/O в горячем пути
Синхронные методы fs удобны для одноразового старта CLI, но в обработчике HTTP они блокируют event loop. Promise API позволяет ожидать файловую операцию без удержания JavaScript-потока. Запись должна быть атомарной с точки зрения наблюдателя: сначала временный файл, затем rename.
import { readFile, rename, writeFile } from "node:fs/promises";
export async function saveTasks(path: string, tasks: Task[]) {
const temporary = `${path}.tmp`;
await writeFile(temporary, JSON.stringify(tasks, null, 2), "utf8");
await rename(temporary, path);
}
export async function loadTasks(path: string): Promise<Task[]> {
const json = await readFile(path, "utf8");
return JSON.parse(json) as unknown as Task[];
}
Последняя строка намеренно оставляет проблему: JSON.parse не валидирует Task. Исправьте её парсером из прошлого урока или схемой. Кроме того, одновременные записи могут потерять обновление — файловый store годится для учебного capstone, но не изображает транзакционную БД. Умение назвать эту границу важнее ложной production-готовности.
Конфигурация и жизненный цикл процесса
process.env содержит строки или undefined. Проверяйте обязательную конфигурацию один раз при старте и передавайте объект зависимостей дальше. Не читайте env внутри каждой доменной функции: тесты станут зависеть от порядка и состояния процесса.
type Config = { port: number; taskFile: string };
export function loadConfig(env = process.env): Config {
const port = Number(env.PORT ?? "3000");
const taskFile = env.TASK_FILE;
if (!Number.isInteger(port) || port < 1) throw new Error("PORT_INVALID");
if (!taskFile) throw new Error("TASK_FILE_REQUIRED");
return { port, taskFile };
}
process.once("SIGTERM", () => {
// Здесь сервер перестанет принимать новые запросы и дождётся активных.
});
Не вызывайте process.exit сразу после SIGTERM в реальном сервере: активные ответы оборвутся. Fastify даст close для корректного завершения. Не логируйте весь process.env — там секреты. Для диагностики достаточно имён проверенных параметров и безопасных значений вроде порта.
node:test: тест как исполняемый контракт
Встроенный test runner Node.js позволяет начать без дополнительного фреймворка. Тест должен проверять наблюдаемое поведение: данные на выходе, ошибку и сохранение инварианта, а не внутреннее количество вызовов map.
import test from "node:test";
import assert from "node:assert/strict";
import { createTaskStore } from "./store.js";
test("setStatus не мутирует исходную задачу", () => {
const original = {
id: "t1", title: "API", status: "todo", createdAt: "2026-01-01T00:00:00.000Z"
} as const;
const store = createTaskStore([original]);
const updated = store.setStatus("t1", "doing");
assert.equal(original.status, "todo");
assert.equal(updated.status, "doing");
assert.notEqual(updated, original);
});
Добавьте npm script test, который сначала компилирует проект, затем запускает node --test по собранным файлам. Тесты должны быть детерминированны: временный каталог создавайте для каждого теста, время и id внедряйте зависимостями, сеть не используйте.
Practice
Task Core v3: runtime под нагрузкой
Добавьте асинхронный файловый adapter с runtime-валидацией JSON, loadConfig и минимум шесть node:test сценариев. Смоделируйте две параллельные операции чтения.
Deliverable
Команды npm run build и npm test, файловый adapter без sync-методов в запросном пути, тесты happy path/invalid JSON/not found/immutability/config/concurrent reads и README с честным ограничением конкурентной записи.
Checkpoint
Объясните эксплуатационную модель Task Core v3 и покажите, что медленный I/O не блокирует JavaScript-поток.
- ✓ Различены конкурентный I/O и CPU-параллелизм.
- ✓ В горячем пути используются node:fs/promises, а не readFileSync/writeFileSync.
- ✓ JSON валидируется до получения Task[], утверждение типа не считается проверкой.
- ✓ Тесты изолируют файлы, время и id и воспроизводимо проходят одной командой.
Check yourself
Sources