Асинхронный код в JavaScript строится на промисах: промис — объект-обещание результата, который сейчас ожидает, а позже выполняется со значением или отклоняется с ошибкой. async/await позволяет писать с промисами как с обычным кодом и ловить ошибки через try/catch. fetch отклоняет промис только при сбое сети или отмене, поэтому статус ответа проверяют через response.ok; независимые запросы запускают одновременно через Promise.all или Promise.allSettled, а отменяют — через AbortController и AbortSignal.timeout().
После материала вы сможете
- объяснить порядок выполнения синхронного кода, микрозадач промисов и таймеров
- написать загрузку данных через
fetchсasync/await, проверкойresponse.okи разделением HTTP-ошибок, сетевых сбоев, отмены и таймаута - запустить независимые запросы параллельно через
Promise.allиPromise.allSettledи выбрать между ними - отменить запрос через
AbortController, ограничить егоAbortSignal.timeout()и объединить сигналыAbortSignal.any()
Что знать заранее
Проверено на
Node.js 24 LTS (24.18.0)JavaScript (ES2024)Fetch APIAbortSignal.timeout / AbortSignal.any (Baseline 2024)Microsoft Edge 155 + Playwright 1.64
Сверено с документацией: 11 октября 2026 г.
Содержание
- Синхронно и асинхронно: цикл событий без глубокой теории
- Промисы: состояния, then, catch и finally
- async/await и try/catch
- fetch: ответ, response.ok и json()
- Параллельно: Promise.all и Promise.allSettled
- Отмена и таймаут: AbortController и AbortSignal.timeout
- Практика: список с загрузкой, ошибкой, повтором и отменой
- Упражнения и что дальше
Синхронно и асинхронно: цикл событий без глубокой теории
JavaScript выполняет код в одном потоке: в каждый момент работает одна инструкция. При этом страница не замирает, пока идёт сетевой запрос. Секрет в цикле событий (event loop): долгие операции — запрос, таймер, чтение файла — выполняет браузер или Node.js, а когда результат готов, в очередь ставится функция, которая его обработает. По MDN, каждая такая задача выполняется до конца, прежде чем начнётся следующая, — её нельзя прервать посередине.
console.log("1. синхронный код: начало");
setTimeout(() => console.log("4. таймер — задача из очереди"), 0);
Promise.resolve().then(() => console.log("3. промис — микрозадача"));
console.log("2. синхронный код: конец");$ node order.mjs
1. синхронный код: начало
2. синхронный код: конец
3. промис — микрозадача
4. таймер — задача из очереди- Сначала весь синхронный код до конца — строки 1 и 2.
- Затем микрозадачи — обработчики промисов. По MDN, они выполняются, как только текущий код закончился и стек пуст, до возврата управления циклу событий.
- Потом следующая задача из очереди — колбэк таймера, даже с задержкой 0.
Промисы: состояния, then, catch и finally
Промис (Promise) — объект, который представляет будущий результат асинхронной операции. По MDN, у него три состояния: ожидание (pending), выполнен (fulfilled) — есть значение, отклонён (rejected) — есть причина-ошибка. Выполненный или отклонённый промис называют завершённым (settled), и его состояние больше не меняется.
// Промис, который выполнится через ms миллисекунд
function wait(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
// Промис, который отклонится через ms миллисекунд
function failAfter(ms, message) {
return new Promise((resolve, reject) => setTimeout(() => reject(new Error(message)), ms));
}
const order = wait(200, "Капучино готов");
console.log(order); // ещё ожидает
order
.then((text) => {
console.log(text);
return text.length; // значение уходит в следующий then
})
.then((length) => console.log("Символов:", length))
.catch((error) => console.log("Ошибка:", error.message))
.finally(() => console.log("Заказ закрыт"));
failAfter(100, "Закончилось молоко")
.then(() => console.log("Сюда не попадём"))
.catch((error) => console.log("Ошибка:", error.message));$ node promise-basics.mjs
Promise { <pending> }
Ошибка: Закончилось молоко
Капучино готов
Символов: 14
Заказ закрыт- Сразу после создания промис в состоянии
<pending>— значения ещё нет. - «Закончилось молоко» напечаталось раньше «Капучино готов»: второй промис завершился через 100 мс, первый — через 200. Порядок вывода определяется временем завершения, а не порядком строк.
thenвозвращает новый промис: то, что вернул обработчик (text.length), получает следующийthen. Так строится цепочка.catchловит отклонение любого шага выше по цепочке,finallyвыполняется в любом случае — удобно, чтобы убрать индикатор загрузки.
pending
- Что значит
- Операция ещё идёт
- Какой обработчик вызовется
- Никакой — пока ждём
fulfilled
- Что значит
- Успех, есть значение
- Какой обработчик вызовется
then, затемfinally
rejected
- Что значит
- Ошибка, есть причина
- Какой обработчик вызовется
catch, затемfinally
async/await и try/catch
Цепочки then быстро становятся громоздкими. async/await — тот же механизм промисов, записанный как обычный последовательный код. По MDN, async-функция всегда возвращает промис, а await приостанавливает только эту функцию до завершения промиса: остальная программа продолжает работать. Если промис отклонён, await выбрасывает ошибку, и её ловит обычный try/catch.
const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
const elapsed = (start) => `${Math.round((performance.now() - start) / 100) * 100} мс`;
async function makeOrder() {
const coffee = await wait(300, "капучино"); // функция ждёт здесь, остальной код — нет
const dessert = await wait(300, "чизкейк");
return `${coffee} и ${dessert}`; // async-функция всегда возвращает промис
}
async function makeOrderParallel() {
const [coffee, dessert] = await Promise.all([wait(300, "капучино"), wait(300, "чизкейк")]);
return `${coffee} и ${dessert}`;
}
let start = performance.now();
console.log(await makeOrder(), "—", elapsed(start));
start = performance.now();
console.log(await makeOrderParallel(), "—", elapsed(start));
async function failingOrder() {
await wait(100);
throw new Error("Кофемашина на обслуживании"); // превращается в отклонённый промис
}
try {
await failingOrder();
} catch (error) {
console.log("Поймали:", error.message);
}$ node async-await.mjs
капучино и чизкейк — 600 мс
капучино и чизкейк — 300 мс
Поймали: Кофемашина на обслуживанииГлавная ловушка — лишняя последовательность. В makeOrder второй await начинается только после первого, и два независимых ожидания по 300 мс заняли 600 мс. В makeOrderParallel оба промиса созданы сразу, а Promise.all ждёт их вместе — 300 мс. Если второй запрос не зависит от результата первого, не ставьте await перед каждым по очереди.
fetch: ответ, response.ok и json()
Из чего состоят HTTP-запрос и ответ и как вызвать fetch, мы разбирали в уроке про HTTP и JSON. Здесь — как обернуть fetch в функцию, которую можно спокойно вызывать из любого места. Ключевой факт из MDN: промис fetch отклоняется только при сетевой ошибке, а ответ 404 или 500 приходит как обычный Response. Поэтому статус проверяют вручную: response.ok равен true для кодов 200–299.
export class HttpError extends Error {
constructor(response) {
super(`HTTP ${response.status} for ${response.url}`);
this.name = "HttpError";
this.status = response.status;
}
}
// fetch + проверка статуса + разбор JSON в одном месте
export async function getJson(url, { signal } = {}) {
const response = await fetch(url, { signal }); // отклоняется только при сетевой ошибке или отмене
if (!response.ok) throw new HttpError(response); // 404 и 500 приходят как обычный ответ
return response.json(); // тоже промис: тело читается потоком
}import { getJson } from "./get-json.mjs";
const API = "http://127.0.0.1:8090/api";
async function show(url) {
try {
const data = await getJson(url);
console.log("OK:", data.map((item) => item.name).join(", "));
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
}
await show(`${API}/menu`);
await show(`${API}/missing`);
await show("http://127.0.0.1:8091/api/menu"); // на этом порту никто не слушает$ node fetch-demo.mjs
OK: Капучино, Латте, Раф, Чизкейк
HttpError: HTTP 404 for http://127.0.0.1:8090/api/missing
TypeError: fetch failedОтвет 2xx
- Что вернул fetch
Response,ok: true- Что выбросит getJson
- Ничего — вернёт данные
Ответ 404, 500, 503
- Что вернул fetch
Response,ok: false- Что выбросит getJson
HttpErrorсоstatus
Сервер недоступен, нет сети
- Что вернул fetch
- Отклонённый промис
- Что выбросит getJson
TypeError(в Node.js — «fetch failed», причина вcause)
Отмена через abort()
- Что вернул fetch
- Отклонённый промис
- Что выбросит getJson
AbortError
Истёк AbortSignal.timeout()
- Что вернул fetch
- Отклонённый промис
- Что выбросит getJson
TimeoutError
response.json() — тоже промис: тело ответа приходит потоком, и разбор начинается, когда оно получено целиком. Если сервер вернул не JSON, этот промис отклонится с SyntaxError.
Параллельно: Promise.all и Promise.allSettled
Странице часто нужно несколько независимых ответов: меню, остатки, акции. Последовательные await сложат время всех запросов, параллельный запуск — займёт время самого долгого. Для этого есть статические методы Promise; два главных — all и allSettled.
import { getJson } from "./get-json.mjs";
const API = "http://127.0.0.1:8090/api";
const elapsed = (start) => `${Math.round((performance.now() - start) / 100) * 100} мс`;
// Promise.all: нужны все результаты, запросы идут одновременно
let start = performance.now();
const [menu, stock] = await Promise.all([
getJson(`${API}/menu?delay=400`),
getJson(`${API}/stock?delay=400`),
]);
console.log(`all: ${menu.length} позиции, зёрен ${stock.beans} кг — ${elapsed(start)}`);
// Если один запрос упал, Promise.all отклоняется целиком
try {
await Promise.all([getJson(`${API}/menu`), getJson(`${API}/promo`)]);
} catch (error) {
console.log("all отклонён:", error.message);
}
// Promise.allSettled: дождаться всех и узнать судьбу каждого
const results = await Promise.allSettled([
getJson(`${API}/menu`),
getJson(`${API}/promo`),
getJson(`${API}/stock`),
]);
for (const r of results) {
console.log("allSettled:", r.status, r.status === "fulfilled" ? "данные есть" : r.reason.message);
}$ node parallel.mjs
all: 4 позиции, зёрен 12 кг — 400 мс
all отклонён: HTTP 503 for http://127.0.0.1:8090/api/promo
allSettled: fulfilled данные есть
allSettled: rejected HTTP 503 for http://127.0.0.1:8090/api/promo
allSettled: fulfilled данные естьPromise.all
- Выполняется массивом значений в том же порядке, что и промисы
- Отклоняется сразу при первой ошибке — остальные результаты теряются
- MDN: когда задачи связаны и без любой из них продолжать нельзя
- Пример: товар и его цена для страницы заказа
Promise.allSettled
- Всегда дожидается всех и не отклоняется
- Возвращает объекты
{ status, value }или{ status, reason } - MDN: когда задачи независимы или нужен итог каждой
- Пример: виджеты дашборда, где сломанная акция не должна скрыть меню
Ещё два метода встречаются реже: Promise.race завершается вместе с первым завершившимся промисом — успешным или нет, Promise.any — с первым успешным и отклоняется, только если упали все. Для таймаута запроса race сегодня не нужен: для этого есть AbortSignal.timeout(), который ещё и останавливает сам запрос.
Отмена и таймаут: AbortController и AbortSignal.timeout
Запрос может стать ненужным: пользователь ушёл со страницы, нажал «Отмена» или ввёл в поиск новый символ. А сервер может не ответить вообще — fetch без ограничения будет ждать долго. Для обоих случаев fetch принимает опцию signal. Сигнал выдаёт AbortController: вызов controller.abort() отклоняет промис fetch с AbortError. AbortSignal.timeout(ms) создаёт сигнал, который сработает сам через заданное время, — с ошибкой TimeoutError, а AbortSignal.any([...]) объединяет несколько сигналов: сработает тот, что раньше.
import { getJson } from "./get-json.mjs";
const SLOW = "http://127.0.0.1:8090/api/slow"; // сервер отвечает через 5 секунд
// 1. Отмена по требованию: пользователь ушёл со страницы, нажал «Отмена»
const controller = new AbortController();
setTimeout(() => controller.abort(), 200);
try {
await getJson(SLOW, { signal: controller.signal });
} catch (error) {
console.log("1:", error.name);
}
// 2. Таймаут: не ждать дольше 500 мс
try {
await getJson(SLOW, { signal: AbortSignal.timeout(500) });
} catch (error) {
console.log("2:", error.name);
}
// 3. И то и другое: что наступит раньше
const userCancel = new AbortController();
const signal = AbortSignal.any([userCancel.signal, AbortSignal.timeout(3000)]);
setTimeout(() => userCancel.abort(), 300);
try {
await getJson(SLOW, { signal });
} catch (error) {
console.log("3:", error.name, "— отмена пользователем раньше таймаута");
}$ node abort.mjs
1: AbortError
2: TimeoutError
3: AbortError — отмена пользователем раньше таймаута- Разные имена ошибок позволяют по-разному реагировать: отмену пользователем обычно молча пропускают, таймаут показывают как ошибку с предложением повторить.
- MDN отмечает, что таймаут считается по активному времени: в фоновой вкладке из кеша «назад-вперёд» отсчёт приостанавливается.
AbortSignal.timeoutиAbortSignal.anyMDN относит к Baseline 2024; в Node.js они есть с версий 17.3 и 20.3. Для очень старых браузеров понадобится запасной вариант наsetTimeoutиcontroller.abort().- Отмена прекращает ожидание ответа на стороне клиента, но сервер мог уже получить и обработать запрос. Отмена не откатывает действие на сервере.
Практика: список с загрузкой, ошибкой, повтором и отменой
Соберём то, ради чего всё это нужно: кнопку «Загрузить меню», которая показывает состояние загрузки, выводит список, при ошибке предлагает «Повторить», а пока запрос идёт — позволяет его отменить. Ждать дольше 5 секунд не будем. Сервер — на node:http без внешних пакетов, он же отдаёт страницу, поэтому запросы идут на тот же адрес и CORS не нужен.
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
const menu = [
{ id: 1, name: "Капучино", price: 1600 },
{ id: 2, name: "Латте", price: 1800 },
{ id: 3, name: "Раф", price: 2200 },
{ id: 4, name: "Чизкейк", price: 2400 },
];
const stock = { beans: 12, milk: 30 };
let flakyCalls = 0;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function sendJson(res, status, data) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
const delay = Number(url.searchParams.get("delay") ?? 0);
if (delay) await wait(delay); // имитация медленной сети
switch (url.pathname) {
case "/":
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
return res.end(await readFile(new URL("index.html", import.meta.url)));
case "/list.js":
res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
return res.end(await readFile(new URL("list.js", import.meta.url)));
case "/api/menu":
return sendJson(res, 200, menu);
case "/api/stock":
return sendJson(res, 200, stock);
case "/api/promo":
return sendJson(res, 503, { error: "Promo service is down" });
case "/api/slow":
await wait(5000);
return sendJson(res, 200, { ok: true });
case "/api/flaky": // первые два запроса — 503, дальше 200
flakyCalls++;
return flakyCalls <= 2
? sendJson(res, 503, { error: "Try again later" })
: sendJson(res, 200, menu);
case "/api/search": {
const q = (url.searchParams.get("q") ?? "").toLowerCase();
return sendJson(res, 200, menu.filter((item) => item.name.toLowerCase().includes(q)));
}
default:
return sendJson(res, 404, { error: "Not found" });
}
});
server.listen(8090, "127.0.0.1", () => console.log("API: http://127.0.0.1:8090"));<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Меню кофейни</title>
<script src="list.js" defer></script>
</head>
<body data-state="idle">
<main>
<h1>Меню</h1>
<button type="button" id="load">Загрузить меню</button>
<button type="button" id="cancel" hidden>Отменить</button>
<p id="status" role="status"></p>
<ul id="menu"></ul>
</main>
</body>
</html>const loadButton = document.querySelector("#load");
const cancelButton = document.querySelector("#cancel");
const statusText = document.querySelector("#status");
const list = document.querySelector("#menu");
let controller = null;
async function getJson(url, signal) {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
function setState(state, message) {
document.body.dataset.state = state; // idle | loading | done | error
statusText.textContent = message;
loadButton.disabled = state === "loading";
loadButton.textContent = state === "error" ? "Повторить" : "Загрузить меню";
cancelButton.hidden = state !== "loading";
}
function renderMenu(items) {
list.replaceChildren(...items.map((item) => {
const li = document.createElement("li");
li.textContent = `${item.name} — ${item.price} ₸`;
return li;
}));
}
async function loadMenu() {
controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]);
setState("loading", "Загружаем меню…");
list.replaceChildren();
try {
const items = await getJson("/api/menu", signal);
renderMenu(items);
setState("done", `Позиций в меню: ${items.length}`);
} catch (error) {
if (error.name === "AbortError") {
setState("idle", "Загрузка отменена");
} else if (error.name === "TimeoutError") {
setState("error", "Сервер не ответил за 5 секунд");
} else {
setState("error", "Не удалось загрузить меню. Проверьте соединение");
}
} finally {
controller = null;
}
}
loadButton.addEventListener("click", loadMenu);
cancelButton.addEventListener("click", () => controller?.abort());Запустите сервер
node server.mjsв папке с тремя файлами и откройтеhttp://127.0.0.1:8090в браузере.Загрузите меню
Нажмите «Загрузить меню»: появятся четыре позиции и строка «Позиций в меню: 4».
Включите медленную сеть
В DevTools на вкладке Network выберите медленный профиль сети и нажмите кнопку снова — успейте нажать «Отменить».
Сломайте сервер
Остановите
server.mjsи нажмите кнопку: появится ошибка и кнопка «Повторить». Запустите сервер снова — повтор загрузит меню.
Успех
- Как имитировали
- Настоящий сервер, ответ задержан на 500 мс
- Результат
- Во время загрузки кнопка заблокирована, «Отменить» видна; затем 4 позиции
Отмена
- Как имитировали
- Ответ не приходит, клик «Отменить» через 200 мс
- Результат
- «Загрузка отменена», список пуст, кнопка снова «Загрузить меню»
Таймаут
- Как имитировали
- Ответ не приходит
- Результат
- Через ~5 с «Сервер не ответил за 5 секунд» и «Повторить»
Ошибка сервера
- Как имитировали
- Ответ 500
- Результат
- Сообщение об ошибке и «Повторить»; повтор с рабочим сервером загрузил меню
Нет сети
- Как имитировали
- Обрыв соединения
- Результат
- Сообщение об ошибке, список очищен
Упражнения и что дальше
Дальше эти приёмы понадобятся в React: загрузка данных в useEffect с отменой при размонтировании компонента. А обёртку над fetch с типами, повторами и единым форматом ошибок мы соберём в рецепте API-клиента на TypeScript.
Как проверено3 · пройденосверено 11 октября 2026 г.
- запущено, работает
order, promise-basics, async-await, get-json, fetch-demo, parallel, abort (RU и EN)
Node.js 24.18.0, локальный server.mjs на 127.0.0.1:8090; каждый язык — 3 прогона со свежим сервером, вывод совпал во всех трёх (тайминги округлены до 100 мс: 600/300 мс, 400 мс); shellsession в статье — реальный вывод; ESLint 10.12 (recommended, globals.node/browser) — 0 замечаний
- запущено, работает
server.mjs + index.html + list.js — практика в браузере (RU и EN)
Microsoft Edge 155 через Playwright 1.64 с настоящим сервером: успех (состояние загрузки, 4 позиции), отмена, таймаут ~5040 мс (AbortSignal.any + timeout), HTTP 500 и повтор, обрыв сети (page.route), role=status, нет ошибок JS; html-validate 0 ошибок
- запущено, работает
решения упражнений: ex-async-order.mjs, retry.mjs, search.mjs
Node.js 24.18.0 со свежим сервером: тот же вывод, что у цепочки then; /api/flaky — 2 × 503 и успех на 3-й попытке, /api/missing — без повторов (404); поиск — [отменён, отменён, Латте]
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.