Загрузка файлов с прогрессом: XHR, fetch и streams
fetch не умеет отдавать прогресс аплоада штатно, XMLHttpRequest умеет, а поверх обоих — сторонние библиотеки и presigned-загрузки в облако. Разбираем, что выбрать под конкретную задачу и чем платишь за каждый вариант.

Вы делаете форму загрузки — фотография аватара, PDF-договор, видео на пару сотен мегабайт. Пользователь жмёт кнопку, и дальше он должен видеть, сколько уже отправлено. Первое, что приходит в голову, — взять fetch, потому что весь остальной код на нём. И тут выясняется: у fetch нет штатного события прогресса на выгрузку. Прогресс скачивания читается через Response.body, а прогресс отправки — нет, и это не баг, а особенность спецификации на момент 2024 года.
Задача звучит просто, но под ней несколько развилок: чем мерить прогресс, куда грузить (свой бэкенд или напрямую в S3-совместимое хранилище), нужна ли пауза и докачка, что делать с файлами в сотни мегабайт. Ниже — четыре подхода, у каждого своя цена.
Почему fetch не показывает прогресс отправки
Скачивание в fetch читается так: response.body — это ReadableStream, вы оборачиваете его в reader и считаете байты по мере поступления. Работает во всех современных браузерах.
С отправкой сложнее. Идея передать ReadableStream в тело запроса существует — это request streams с обязательным параметром duplex: 'half'. Но поддержка неполная: на момент 2024 года стримовые запросы работают в Chromium-браузерах и требуют HTTP/2, а в Safari и Firefox поддержка ограничена или отсутствует. Проверяйте актуальный статус на Can I Use, прежде чем закладывать это в прод.
Вывод простой: если вам нужен надёжный прогресс отправки во всех браузерах прямо сейчас — XMLHttpRequest всё ещё единственный кроссбраузерный способ получить это из коробки.
Подход 1. XMLHttpRequest — рабочая лошадка
У XMLHttpRequest есть объект xhr.upload с событием progress. Оно даёт loaded и total в байтах.
// Работает во всех современных браузерах, 2024
function uploadWithProgress(file, url, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
// прогресс именно отправки — на xhr.upload, не на xhr
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.responseText);
} else {
reject(new Error('Сервер ответил ' + xhr.status));
}
});
xhr.addEventListener('error', () => reject(new Error('Сетевая ошибка')));
xhr.addEventListener('abort', () => reject(new Error('Загрузка отменена')));
xhr.open('POST', url);
xhr.send(formData);
});
}Отмена делается через xhr.abort() — сохраните ссылку на xhr снаружи промиса, если нужна кнопка «отмена».
Цена: API старое и многословное, промисификация — на вас, дерево addEventListener вместо цепочки .then. Но прогресс работает везде и без библиотек.
Если вам от загрузчика нужен только один прогресс-бар и он должен работать в Safari — берите XMLHttpRequest и не усложняйте. Это не устаревшая технология, а самый прямой путь к результату.
Подход 2. fetch + ReadableStream — только скачивание
Для полноты: если вам нужен прогресс скачивания (например, тянете большой JSON или файл на клиент), fetch подходит идеально.
// Прогресс скачивания через ReadableStream, fetch
async function downloadWithProgress(url, onProgress) {
const response = await fetch(url);
const total = Number(response.headers.get('Content-Length')) || 0;
const reader = response.body.getReader();
const chunks = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
if (total) onProgress(Math.round((received / total) * 100));
}
return new Blob(chunks);
}Важно: прогресс здесь честный, только если сервер прислал заголовок Content-Length и не отдаёт ответ через chunked-кодирование без указания длины. Иначе total будет нулём и процент посчитать нельзя — придётся показывать индикатор без цифры.
Подход 3. Библиотека поверх XHR — Axios и подобные
Если проект уже на Axios, прогресс отправки закрывается опцией onUploadProgress — под капотом там тот же XMLHttpRequest.
// axios ^1.x
import axios from 'axios';
const formData = new FormData();
formData.append('file', file);
await axios.post('/api/upload', formData, {
onUploadProgress: (event) => {
if (event.total) {
const percent = Math.round((event.loaded / event.total) * 100);
console.log(percent);
}
},
});Здесь event.total может быть undefined — проверяйте перед делением, иначе получите NaN в прогресс-баре.
Цена: тащить Axios ради одного аплоада незачем — это лишний вес в бандле. Но если он уже есть в проекте, это самый короткий код из всех вариантов.
Подход 4. Прямая загрузка в облако — большие файлы
Когда файлы измеряются сотнями мегабайт или гигабайтами, гнать их через свой бэкенд расточительно: сервер держит соединение, буферизует, платит за трафик дважды. Правильнее — presigned URL: бэкенд выдаёт временную подписанную ссылку в S3-совместимое хранилище, а браузер грузит файл напрямую.
Прогресс при этом снимается тем же xhr.upload.progress — вы просто делаете PUT на выданный URL вместо POST на свой сервер. Для файлов больше 100 МБ дополнительно смотрите в сторону multipart upload: файл режется на части, каждая грузится отдельно, части докачиваются при обрыве. Логика разбиения и сборки — отдельная большая тема, здесь мы её только обозначаем.
Что выбирать
| Подход | Прогресс отправки | Когда брать | Чем платишь |
|---|---|---|---|
| XMLHttpRequest | Да, везде | Нужен прогресс аплоада, поддержка Safari | Многословный старый API |
| fetch + streams | Только скачивание | Прогресс загрузки на клиент | Отправку не покрывает штатно |
| Axios / библиотека | Да (поверх XHR) | Библиотека уже в проекте | Вес бандла ради одной формы |
| Presigned + S3 | Да (XHR PUT) | Файлы от сотен МБ | Настройка хранилища, CORS, multipart |
Критерий выбора без «зависит от задачи»: нужен прогресс отправки и один браузер не Chromium — XMLHttpRequest. Файл влезает в один запрос и Axios уже есть — onUploadProgress. Файл гигабайтный — presigned URL с multipart. fetch для отправки с прогрессом не берите, пока request streams не станут кроссбраузерными.
Что ломается на практике
- lengthComputable = false. Если браузер не знает размер тела (редко, но бывает при потоковых источниках),
event.totalбудет 0. Всегда проверяйте флаг перед расчётом процента. - Прогресс до 100%, а запрос висит. Событие
upload.progressотражает отправку байтов в сеть, а не обработку на сервере. После 100% файл ещё пишется на диск или в хранилище — покажите состояние «обработка», а не «готово». - CORS при прямой загрузке в S3. Presigned
PUTиз браузера требует настроенной CORS-политики на бакете, иначе браузер заблокирует запрос без внятной ошибки в консоли. - NaN в баре. Деление на
undefinedtotal в Axios — самая частая причина сломанного индикатора.
Что делать дальше
Начните с XMLHttpRequest, если сомневаетесь — он покрывает 90% задач с прогрессом отправки и работает везде. Обёртку из первого примера можно завернуть в свой хук или сервис и переиспользовать. Когда упрётесь в большие файлы — добавляйте presigned URL и изучайте multipart upload вашего хранилища и AbortController для отмены и докачки. Замерьте в проде долю загрузок, которые обрываются на середине: если она заметная, докачка окупит настройку раньше, чем кажется.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: MDN — XMLHttpRequest: upload и события прогресса, Can I Use — ReadableStream в теле Request
Частые вопросы
Почему у fetch нет прогресса отправки, а у XMLHttpRequest есть?
А если у меня Safari или Firefox — request streams заработают?
Прогресс дошёл до 100%, но загрузка не завершается — что не так?
Как отменить загрузку в процессе?
Стоит ли грузить большие файлы через свой бэкенд?
Почему прогресс-бар показывает NaN?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.