Серверный JavaScript: как работает Node.js и с чего начать

Серверный JavaScript: как работает Node.js и с чего начать Полезное

Серверный JavaScript — это код на JavaScript, который выполняется не в браузере пользователя, а в отдельной среде выполнения на сервере: принимает HTTP-запросы, читает файлы и базы данных, отдает ответы. Самая распространенная такая среда — Node.js, альтернативы — Deno и Bun.

Ниже — словарь терминов, минимальный сервер, event loop на примерах, npm, сравнение сред и типовые ошибки. Примеры проверены на Node.js 22.23 (сентябрь 2026); используемые API есть и в актуальной LTS-ветке Node.js 24.

Словарь: язык, движок, среда, фреймворк

Эти понятия часто сливают в одно «Node — это редактор JS», хотя это разные уровни.

Термин Что это Пример
Язык Синтаксис и семантика по стандарту ECMAScript let, async/await, Promise
Движок Программа, которая компилирует и исполняет JS-код V8 (Chrome, Node.js, Deno), JavaScriptCore (Safari, Bun)
Среда выполнения (runtime) Движок плюс API окружения и цикл событий Node.js, Deno, Bun, браузер
Фреймворк Библиотека поверх среды со структурой приложения Express, Fastify, NestJS
Редактор / IDE Программа, в которой пишут код, к выполнению отношения не имеет VS Code, WebStorm, Vim

Главное различие для новичка: язык в браузере и в Node.js один, а доступные объекты разные. В браузере есть window, document и DOM, но нет прямого доступа к файлам. В Node.js нет DOM, зато есть модули node:fs, node:http, node:path и объект process с переменными окружения и аргументами запуска.

Минимальный HTTP-сервер на Node.js

Сначала полный пример целиком. Он отвечает на GET /health, принимает JSON на POST /echo и отклоняет слишком большие и некорректные запросы. Сохраните как server.mjs (расширение .mjs включает синтаксис import) и запустите node server.mjs.

import http from 'node:http';

const MAX_BODY = 1024 * 1024; // 1 МБ - защитный лимит тела запроса

const server = http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/health') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ status: 'ok', pid: process.pid }));
    return;
  }

  if (req.method === 'POST' && req.url === '/echo') {
    let size = 0;
    const chunks = [];
    req.on('data', (chunk) => {
      size += chunk.length;
      if (size > MAX_BODY) {
        res.writeHead(413, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'payload too large' }));
        req.destroy();
        return;
      }
      chunks.push(chunk);
    });
    req.on('end', () => {
      try {
        const data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ received: data }));
      } catch {
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'invalid JSON' }));
      }
    });
    return;
  }

  res.writeHead(404, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ error: 'not found' }));
});

const PORT = Number(process.env.PORT) || 3000;
server.listen(PORT, '127.0.0.1', () => {
  console.log(`Server: http://127.0.0.1:${PORT}`);
});

В консоли появится Server: http://127.0.0.1:3000. Проверка из второго терминала:

curl -s http://127.0.0.1:3000/health
curl -s -X POST http://127.0.0.1:3000/echo -H 'Content-Type: application/json' -d '{"name":"Otus"}'
curl -s -X POST http://127.0.0.1:3000/echo -d '{name:1}'
{"status":"ok","pid":80095}
{"received":{"name":"Otus"}}
{"error":"invalid JSON"}

Тело размером 2 МБ получит ответ 413 с {"error":"payload too large"}, неизвестный путь — 404 Not Found. Номер pid у вас будет другим.

Что здесь важно:

  • http.createServer получает функцию-обработчик, которую Node.js вызывает на каждый запрос. Сервер не создает поток на запрос: все обработчики выполняются по очереди в одном потоке JavaScript.
  • Тело запроса приходит частями (событие data), поэтому его собирают и разбирают в end. Лимит размера нужен, иначе один клиент может занять память процесса.
  • JSON.parse обернут в try/catch: без этого некорректный JSON от клиента выбросит исключение, и процесс упадет.
  • listen(PORT, '127.0.0.1') слушает только локальный интерфейс. Для доступа извне сервер обычно ставят за обратный прокси (nginx или балансировщик), который берет на себя TLS.

Это учебный минимум, а не готовый production-сервис: в реальном API добавляют аутентификацию, таймауты, журналирование, ограничение частоты запросов и обычно используют фреймворк (Express, Fastify, NestJS), который закрывает маршрутизацию и разбор тела.

Как работает Node.js: event loop и неблокирующий ввод-вывод

Упрощенная модель: ваш JavaScript-код выполняется в одном потоке, а долгие операции ввода-вывода (сеть, диск) Node.js отдает системе и библиотеке libuv. Когда операция завершается, ее колбэк ставится в очередь, и цикл событий (event loop) вызывает его, как только поток свободен. Поэтому один процесс держит тысячи медленных соединений: ожидание сети не занимает поток.

Граница модели: «один поток» относится к вашему JS-коду. Внутри Node.js есть пул потоков libuv (по умолчанию 4, настраивается UV_THREADPOOL_SIZE) для файловых операций, dns.lookup, части crypto и zlib, а сетевые сокеты обслуживаются механизмами ОС (epoll, kqueue, IOCP).

Порядок выполнения видно на примере order.mjs:

import { readFile } from 'node:fs';

console.log('A: синхронный код');

Promise.resolve().then(() => console.log('B: микрозадача Promise'));
process.nextTick(() => console.log('C: process.nextTick'));

readFile(import.meta.filename, () => {
  console.log('D: файл прочитан, колбэк ввода-вывода');
  setTimeout(() => console.log('F: setTimeout 0 из колбэка'), 0);
  setImmediate(() => console.log('E: setImmediate из колбэка'));
});

console.log('A2: синхронный код закончился');
A: синхронный код
A2: синхронный код закончился
B: микрозадача Promise
C: process.nextTick
D: файл прочитан, колбэк ввода-вывода
E: setImmediate из колбэка
F: setTimeout 0 из колбэка

Как читать результат. Сначала до конца выполняется синхронный код (A, A2). Затем очереди микрозадач (B, C). Потом, когда ОС вернула файл, — колбэк ввода-вывода (D). Внутри колбэка ввода-вывода setImmediate всегда срабатывает раньше setTimeout(..., 0): фаза check идет сразу после фазы poll, а таймеры — на следующем обороте цикла.

Две оговорки, проверенные прогоном. В ES-модуле (.mjs) Promise выполнился раньше nextTick, а тот же код в CommonJS (.cjs) печатает C раньше B: верхний уровень ES-модуля сам выполняется внутри механизма промисов. И на верхнем уровне скрипта порядок setTimeout(..., 0) и setImmediate не гарантирован, поэтому в примере они вызваны из колбэка.

Главная ловушка: блокировка потока

Раз поток один, долгий синхронный расчет останавливает все: таймеры, другие запросы, ответы.

const start = Date.now();
setTimeout(() => {
  console.log(`таймер на 100 мс сработал через ${Date.now() - start} мс`);
}, 100);

// Синхронный CPU-цикл на ~1 секунду занимает единственный поток JS
while (Date.now() - start < 1000) {}
console.log('цикл закончился');
цикл закончился
таймер на 100 мс сработал через 1003 мс

Таймер опоздал на 900 мс. В сервере то же самое означает, что все клиенты ждут, пока один запрос считает. Исправление для CPU-задач — вынести расчет в отдельный поток через worker_threads:

import { Worker } from 'node:worker_threads';

const start = Date.now();
setTimeout(() => {
  console.log(`таймер на 100 мс сработал через ${Date.now() - start} мс`);
}, 100);

// Тот же цикл на ~1 секунду, но в отдельном потоке
const worker = new Worker(
  'const s = Date.now(); while (Date.now() - s < 1000) {} require("node:worker_threads").parentPort.postMessage("готово");',
  { eval: true }
);
worker.on('message', (msg) => {
  console.log(`воркер: ${msg} через ${Date.now() - start} мс`);
});
таймер на 100 мс сработал через 101 мс
воркер: готово через 1013 мс

Миллисекунды на вашей машине будут немного другими, важна разница: основной поток остался свободен. Для файлов и сети блокировку лечат иначе — асинхронными API (fs.promises.readFile вместо fs.readFileSync в обработчике запроса).

npm, package.json и модули

npm — менеджер пакетов, который ставится вместе с Node.js. Базовый набор команд для нового проекта:

npm init -y
npm pkg set type=module
npm install fastify
npm ci

npm init -y создает package.json с описанием проекта. "type": "module" делает файлы .js ES-модулями (import/export). npm install добавляет зависимость и записывает точную версию в package-lock.json, а npm ci на сервере и в CI ставит ровно то, что зафиксировано в lock-файле.

В Node.js сосуществуют две системы модулей, и их смешение — частая ошибка:

CommonJS ES-модули
Подключение require('node:fs') import fs from 'node:fs'
Расширение .cjs или .js без "type": "module" .mjs или .js с "type": "module"
Для новых проектов legacy, но встречается повсюду стандарт языка, рекомендуемый вариант

Безопасность зависимостей: пакет из npm выполняет код с правами вашего процесса, а скрипты установки — уже при npm install. Поэтому lock-файл хранят в репозитории, новые пакеты проверяют перед установкой, смотрят npm audit, а секреты держат в переменных окружения, не в коде.

Node.js, Deno и Bun

Node.js Deno Bun
Движок V8 V8 JavaScriptCore
Пакеты npm свой реестр JSR и совместимость с npm npm-совместимый менеджер встроен
TypeScript снятие типов встроено в свежих версиях, полная проверка — через tsc запускает .ts из коробки запускает .ts из коробки
Доступ к сети и файлам по умолчанию разрешен запрещен, пока не выдан флагом (--allow-net) по умолчанию разрешен
Когда брать основной выбор, больше всего библиотек нужна изоляция прав важна скорость старта, совместимость пакетов проверять

Для первого серверного проекта разумно начать с Node.js: документация, фреймворки и ответы на типовые ошибки в первую очередь написаны под него.

Когда серверный JavaScript подходит

Хорошо ложится на API, чаты и уведомления на WebSocket, BFF-слой между фронтендом и сервисами, серверный рендеринг (Next.js, Nuxt). Тяжелые вычисления (видео, ML) выносят в worker_threads, очередь задач или сервис на другом языке.

Если не получилось

  • Error: listen EADDRINUSE: address already in use 127.0.0.1:3000 — порт занят, часто вашим же прошлым запуском. Остановите старый процесс или запустите на другом порту: PORT=3001 node server.mjs.
  • ReferenceError: require is not defined in ES module scope, you can use import instead — файл считается ES-модулем (есть "type": "module"). Замените require на import или переименуйте файл в .cjs.
  • Warning: Module type of file ... is not specified and it doesn't parse as CommonJS — вы пишете import в .js, а в package.json не указан тип. Добавьте "type": "module".
  • Сервер «зависает» на всех запросах сразу — ищите синхронный тяжелый код в обработчике (цикл, readFileSync, JSON.parse огромного тела).

Выводы

  • Серверный JavaScript — это тот же язык, но в среде выполнения (Node.js, Deno, Bun) с доступом к сети, файлам и процессу вместо DOM.
  • Node.js выполняет ваш код в одном потоке и не ждет ввод-вывод, а получает колбэки через event loop; поэтому он хорош для приложений с большим числом соединений.
  • Синхронный тяжелый код блокирует всех клиентов сразу; CPU-задачи выносят в worker_threads или отдельный сервис, файлы и сеть читают асинхронно.
  • Для новых проектов — ES-модули, lock-файл и npm ci, ограничение размера входных данных и обработка ошибок разбора.

Где применяется / связь с практикой

Освойте тему на практике

Серверный JavaScript нужен бэкенд- и fullstack-разработчикам: API для веб- и мобильных приложений, real-time сервисы, серверный рендеринг сайтов. Следующий шаг после минимального сервера — фреймворк, работа с базой данных, тесты, авторизация и выкладка в контейнере. Эти темы по порядку разбирают на курсе Node.js Developer, а попробовать формат и задать вопросы преподавателям можно на бесплатных открытых уроках Otus.

FAQ

Можно ли писать серверный код на TypeScript?
Да. Популярный вариант — TypeScript с компиляцией tsc или запуск через Deno и Bun, которые понимают .ts сами; в Node.js проверку типов по-прежнему делает tsc.

Однопоточный Node.js использует только одно ядро процессора?
Один процесс исполняет JS на одном ядре, но несколько ядер задействуют запуском нескольких процессов (модуль cluster, менеджер процессов, несколько контейнеров за балансировщиком) или worker_threads.

Нужен ли Express, если есть модуль node:http?
Для учебы хватает node:http; в реальном API фреймворк берет на себя маршрутизацию, разбор тела и middleware.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)