← From JavaScript to Fastify: one Task API, four levels
03 / 04 · 75 min

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

Что произойдёт с event loop при тяжёлом синхронном цикле внутри async-функции?

01Что произойдёт с event loop при тяжёлом синхронном цикле внутри async-функции?

Почему fs.readFileSync опасен в HTTP-обработчике?

02Почему fs.readFileSync опасен в HTTP-обработчике?

Что лучше всего проверяет тест доменной функции?

03Что лучше всего проверяет тест доменной функции?

Sources

Node.js as a backend runtime: event loop, I/O and built-in tests · From JavaScript to Fastify: one Task API, four levels · VORCL