Как делать11 октября 2026 г.7 мин чтения
JavaScript с нуляШаг 8 из 9

Асинхронный JavaScript: промисы, async/await и fetch

Коротко

Асинхронный код в JavaScript строится на промисах: промис — объект-обещание результата, который сейчас ожидает, а позже выполняется со значением или отклоняется с ошибкой. async/await позволяет писать с промисами как с обычным кодом и ловить ошибки через try/catch. fetch отклоняет промис только при сбое сети или отмене, поэтому статус ответа проверяют через response.ok; независимые запросы запускают одновременно через Promise.all или Promise.allSettled, а отменяют — через AbortController и AbortSignal.timeout().

Пошаговый урокУровень: СреднийJavaScriptПрактика: ~1,5–3 ч

После материала вы сможете

  • объяснить порядок выполнения синхронного кода, микрозадач промисов и таймеров
  • написать загрузку данных через 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 г.

Содержание
  1. 01Синхронно и асинхронно: цикл событий без глубокой теории
  2. 02Промисы: состояния, then, catch и finally
  3. 03async/await и try/catch
  4. 04fetch: ответ, response.ok и json()
  5. 05Параллельно: Promise.all и Promise.allSettled
  6. 06Отмена и таймаут: AbortController и AbortSignal.timeout
  7. 07Практика: список с загрузкой, ошибкой, повтором и отменой
  8. 08Упражнения и что дальше

Синхронно и асинхронно: цикл событий без глубокой теории

JavaScript выполняет код в одном потоке: в каждый момент работает одна инструкция. При этом страница не замирает, пока идёт сетевой запрос. Секрет в цикле событий (event loop): долгие операции — запрос, таймер, чтение файла — выполняет браузер или Node.js, а когда результат готов, в очередь ставится функция, которая его обработает. По MDN, каждая такая задача выполняется до конца, прежде чем начнётся следующая, — её нельзя прервать посередине.

order.mjs
console.log("1. синхронный код: начало");

setTimeout(() => console.log("4. таймер — задача из очереди"), 0);

Promise.resolve().then(() => console.log("3. промис — микрозадача"));

console.log("2. синхронный код: конец");
Четыре строки — в каком порядке они появятся?
terminal
$ node order.mjs
1. синхронный код: начало
2. синхронный код: конец
3. промис — микрозадача
4. таймер — задача из очереди
Реальный вывод Node.js 24. В браузере порядок тот же.
  • Сначала весь синхронный код до конца — строки 1 и 2.
  • Затем микрозадачи — обработчики промисов. По MDN, они выполняются, как только текущий код закончился и стек пуст, до возврата управления циклу событий.
  • Потом следующая задача из очереди — колбэк таймера, даже с задержкой 0.

Промисы: состояния, then, catch и finally

Промис (Promise) — объект, который представляет будущий результат асинхронной операции. По MDN, у него три состояния: ожидание (pending), выполнен (fulfilled) — есть значение, отклонён (rejected) — есть причина-ошибка. Выполненный или отклонённый промис называют завершённым (settled), и его состояние больше не меняется.

promise-basics.mjs
// Промис, который выполнится через 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));
Два промиса на таймерах: один выполняется через 200 мс, другой отклоняется через 100 мс.
terminal
$ node promise-basics.mjs
Promise { <pending> }
Ошибка: Закончилось молоко
Капучино готов
Символов: 14
Заказ закрыт
Реальный вывод Node.js 24.
  • Сразу после создания промис в состоянии <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.

async-await.mjs
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);
}
Последовательное ожидание, параллельное ожидание и ошибка внутри async-функции. Время округлено до 100 мс.
terminal
$ node async-await.mjs
капучино и чизкейк — 600 мс
капучино и чизкейк — 300 мс
Поймали: Кофемашина на обслуживании
Реальный вывод Node.js 24: одинаковый результат за 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.

get-json.mjs
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();                         // тоже промис: тело читается потоком
}
Помощник для всех примеров ниже: ошибка HTTP превращается в исключение со статусом.
fetch-demo.mjs
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");   // на этом порту никто не слушает
Три запроса к локальному серверу: успешный, несуществующий адрес и порт, где никто не слушает.
terminal
$ node fetch-demo.mjs
OK: Капучино, Латте, Раф, Чизкейк
HttpError: HTTP 404 for http://127.0.0.1:8090/api/missing
TypeError: fetch failed
Реальный вывод Node.js 24 при запущенном server.mjs из практики.

Ответ 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.

parallel.mjs
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);
}
Сервер отдаёт меню и остатки с задержкой 400 мс, сервис акций отвечает 503.
terminal
$ 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 данные есть
Реальный вывод Node.js 24: два запроса по 400 мс заняли 400 мс, а не 800.

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([...]) объединяет несколько сигналов: сработает тот, что раньше.

abort.mjs
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, "— отмена пользователем раньше таймаута");
}
Сервер отвечает на /api/slow через 5 секунд; все три запроса прерываются раньше.
terminal
$ node abort.mjs
1: AbortError
2: TimeoutError
3: AbortError — отмена пользователем раньше таймаута
Реальный вывод Node.js 24.
  • Разные имена ошибок позволяют по-разному реагировать: отмену пользователем обычно молча пропускают, таймаут показывают как ошибку с предложением повторить.
  • MDN отмечает, что таймаут считается по активному времени: в фоновой вкладке из кеша «назад-вперёд» отсчёт приостанавливается.
  • AbortSignal.timeout и AbortSignal.any MDN относит к Baseline 2024; в Node.js они есть с версий 17.3 и 20.3. Для очень старых браузеров понадобится запасной вариант на setTimeout и controller.abort().
  • Отмена прекращает ожидание ответа на стороне клиента, но сервер мог уже получить и обработать запрос. Отмена не откатывает действие на сервере.

Практика: список с загрузкой, ошибкой, повтором и отменой

Соберём то, ради чего всё это нужно: кнопку «Загрузить меню», которая показывает состояние загрузки, выводит список, при ошибке предлагает «Повторить», а пока запрос идёт — позволяет его отменить. Ждать дольше 5 секунд не будем. Сервер — на node:http без внешних пакетов, он же отдаёт страницу, поэтому запросы идут на тот же адрес и CORS не нужен.

server.mjs
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"));
Учебный API: меню, остатки, падающий сервис акций, медленный ответ, «нестабильный» адрес и поиск. Параметр delay имитирует медленную сеть.
index.html
<!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>
Разметка: кнопки, строка статуса с role="status" для скринридеров и пустой список.
list.js
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());
Вся логика: состояние, загрузка с отменой и таймаутом, вывод через textContent.
  1. Запустите сервер

    node server.mjs в папке с тремя файлами и откройте http://127.0.0.1:8090 в браузере.

  2. Загрузите меню

    Нажмите «Загрузить меню»: появятся четыре позиции и строка «Позиций в меню: 4».

  3. Включите медленную сеть

    В DevTools на вкладке Network выберите медленный профиль сети и нажмите кнопку снова — успейте нажать «Отменить».

  4. Сломайте сервер

    Остановите server.mjs и нажмите кнопку: появится ошибка и кнопка «Повторить». Запустите сервер снова — повтор загрузит меню.

Успех

Как имитировали
Настоящий сервер, ответ задержан на 500 мс
Результат
Во время загрузки кнопка заблокирована, «Отменить» видна; затем 4 позиции

Отмена

Как имитировали
Ответ не приходит, клик «Отменить» через 200 мс
Результат
«Загрузка отменена», список пуст, кнопка снова «Загрузить меню»

Таймаут

Как имитировали
Ответ не приходит
Результат
Через ~5 с «Сервер не ответил за 5 секунд» и «Повторить»

Ошибка сервера

Как имитировали
Ответ 500
Результат
Сообщение об ошибке и «Повторить»; повтор с рабочим сервером загрузил меню

Нет сети

Как имитировали
Обрыв соединения
Результат
Сообщение об ошибке, список очищен
Сценарии, которые мы прогнали в Edge 155 через Playwright

Упражнения и что дальше

Дальше эти приёмы понадобятся в 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.