Загрузка файлов с прогрессом: типичные ошибки фронтенда
Прогресс-бар при загрузке файла ломается там, где его не ждут: fetch не даёт события прогресса, а прыгающий процент — это не баг сети, а особенность буфера. Разбираем частые ошибки и рабочие приёмы.

Вы прикрутили прогресс-бар к загрузке файла, всё выглядит хорошо на локалхосте — а в проде процент долетает до 100% за секунду и потом полминуты висит. Или наоборот: вы взяли fetch вместо старого XMLHttpRequest, а событий прогресса на отправку у него нет вовсе. Ниже — конкретные грабли, на которые наступают при реализации аплоада с индикатором, и что с каждой делать.
Материал про клиентскую часть: браузер, JavaScript, отправка на бэкенд. Серверная валидация и хранение — отдельная тема, здесь не разбираем.
Ошибка 1. Взять fetch и ждать от него прогресс отправки
fetch — удобный API, но у него нет способа отследить прогресс отправки тела запроса. ReadableStream в поле body при запросах поддерживается ограниченно и требует HTTP/2, а стабильного кроссбраузерного события «отправлено N из M байт» у fetch на аплоаде нет по сей день.
Прогресс скачивания ответа через response.body.getReader() у fetch есть. А вот прогресс аплоада — нет. Для него по-прежнему живёт XMLHttpRequest, у которого событие даёт xhr.upload.
// Рабочий приём: XHR ради события прогресса отправки
function uploadFile(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const form = new FormData();
form.append('file', file);
// Событие прогресса именно на отправку тела
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.response);
else reject(new Error('HTTP ' + xhr.status));
});
xhr.addEventListener('error', () => reject(new Error('Сетевая ошибка')));
xhr.open('POST', '/api/upload');
xhr.send(form);
});
}Обратите внимание: обработчик прогресса вешается на xhr.upload, а не на сам xhr. Событие progress на самом объекте отследит скачивание ответа, а не отправку файла — это частая путаница.
Прогресс-бар аплоада в 2024 году — единственная причина, по которой XMLHttpRequest всё ещё пишут руками. Если прогресс не нужен, берите fetch.
Ошибка 2. Считать, что 100% на клиенте — это «файл загружен»
Событие upload.progress с loaded === total означает, что браузер отдал все байты в сетевой буфер. Это не то же самое, что «сервер принял и обработал файл». Между «отдали последний байт» и «пришёл ответ 200» проходит время: сервер читает тело, пишет на диск, отвечает.
Отсюда классический эффект: бар доходит до 100% и подвисает. Пользователь думает, что всё зависло. Правильная модель — два состояния, а не одно:
- Загрузка — от 0 до 100% по событию
upload.progress. - Обработка — после 100% и до события
load. Показывайте здесь спиннер или подпись «Обрабатываем», а не замерший прогресс-бар.
Не гоните бар до 100% на событии прогресса. Оставьте потолок на 99% и добейте до 100 только в обработчике load — тогда «полностью заполненный бар» честно означает «готово».
Ошибка 3. Дёргать состояние React на каждое событие
Событие progress прилетает часто — при загрузке большого файла это десятки вызовов в секунду. Если на каждый вызывать setState (в React) или менять реактивную переменную, вы получите шквал перерисовок. На слабом устройстве это заметно тормозит саму загрузку.
Округляйте до целого процента и обновляйте состояние только при изменении значения:
let lastPercent = -1;
xhr.upload.addEventListener('progress', (event) => {
if (!event.lengthComputable) return;
const percent = Math.round((event.loaded / event.total) * 100);
// Обновляем UI только когда целое число процентов реально изменилось
if (percent !== lastPercent) {
lastPercent = percent;
onProgress(percent);
}
});Ошибка 4. Забыть про отмену и таймаут
Пользователь выбрал файл на 500 МБ по ошибке — ему нужна кнопка «Отмена». С XMLHttpRequest это xhr.abort(). Держите ссылку на xhr вне промиса, чтобы дотянуться до неё из обработчика клика.
Если вы всё-таки используете fetch (например, только для скачивания или там, где прогресс не нужен), отмена делается через AbortController:
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then((response) => response.json())
.catch((err) => {
if (err.name === 'AbortError') {
// Пользователь отменил — это не ошибка, а штатный сценарий
return;
}
throw err;
});
// По клику на «Отмена»
controller.abort();У XMLHttpRequest есть xhr.timeout в миллисекундах и событие timeout. Без него зависший запрос будет висеть до дефолтного сетевого таймаута браузера — а это может быть очень долго.
Ошибка 5. Прогресс прыгает и это списывают на сеть
Часто прогресс идёт рывками: замирает, потом скачет на 20%. Причина обычно не в сети, а в буферизации. Байты уходят в буфер операционной системы пачками, и event.loaded обновляется не гладко. Бороться с этим на клиенте нельзя — можно только сгладить визуально.
Не анимируйте бар透 через JS-таймеры, имитируя «плавность»: это ложь пользователю и лишняя работа. Достаточно CSS-перехода на ширину:
.progress-bar {
transition: width 0.2s ease-out;
}Так резкие скачки width визуально сглаживаются, а реальные данные остаются честными.
Сравнение подходов к прогрессу
| Задача | API | Прогресс отправки | Прогресс скачивания | Отмена |
|---|---|---|---|---|
| Аплоад файла с баром | XMLHttpRequest | Есть (xhr.upload) | Есть | xhr.abort() |
| Обычный запрос без прогресса | fetch | Нет | Есть (через reader) | AbortController |
| Скачивание с баром | fetch + ReadableStream | — | Есть | AbortController |
Ошибка 6. Не проверять lengthComputable
Свойство event.lengthComputable равно false, если общий размер неизвестен — тогда event.total будет нулём, а деление даст Infinity или NaN. Для загрузки локального файла размер обычно известен, но привычка проверять флаг избавляет от неожиданного «прогресса на бесконечность».
Если бар доходит до 100% и висит
- Сервер долго обрабатывает — это нормально. Покажите отдельное состояние «Обрабатываем», не оставляйте замерший бар. См. ошибку 2.
- Прокси буферизует запрос — некоторые обратные прокси (например, конфигурация Nginx с включённой буферизацией тела) принимают весь файл до передачи на бэкенд. Клиент видит 100%, а бэкенд ещё не начал. Настройка буферизации — на стороне инфраструктуры.
- Событие load не приходит — проверьте, что вешаете обработчики
loadиerrorдоsend(), иначе можно пропустить событие.
Что настроить дальше
Клиентский прогресс — это половина. Для файлов от сотен мегабайт и выше клиентский аплоад одним запросом хрупок: разрыв сети — и всё с нуля. Следующий шаг — chunked-загрузка (файл режется на части через Blob.slice, каждая отправляется отдельно, прогресс складывается из частей) и возобновляемая загрузка по протоколу вроде tus. Это отдельная тема, но именно туда стоит смотреть, если пользователи жалуются на обрывы.
На стороне UX смотрите на один показатель: сколько времени пользователь видит замерший бар после 100%. Если это заметно — вводите состояние «Обрабатываем» и не тяните бар до 100% раньше события load.
Универсальный ассистент: Обычный чат: тексты, идеи, разбор, перевод, расчёты.
Спросить за 8 ₽Источники: XMLHttpRequest: upload — MDN Web Docs, AbortController — MDN Web Docs
Частые вопросы
Почему fetch не показывает прогресс загрузки файла?
А если мне нужен прогресс скачивания, а не загрузки?
Прогресс доходит до 100% и висит — это баг?
Можно ли отменить загрузку в процессе?
Прогресс прыгает рывками — что не так с сетью?
У меня другая версия окружения, приёмы всё ещё актуальны?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.