Загрузка файлов с прогрессом: чек-лист перед продакшеном
Прогресс-бар на fetch не работает при аплоаде, а на 2-гигабайтном файле браузер съедает память и отваливается по таймауту. Разбираем, что проверить до релиза.

Форма с прогресс-баром работает на тестовом файле в 3 мегабайта и падает на реальном видео в 800 мегабайт: браузер держит запрос десять минут, потом ловит таймаут прокси, а пользователь видит 100% и «загрузка не удалась». Ещё популярнее — прогресс-бар, который на fetch() просто не двигается, потому что стандартный Fetch API не отдаёт события прогресса отправки. Ниже — что проверить перед выкатом загрузки файлов в прод, с конкретными подводными камнями браузерных API.
Материал про клиентскую часть загрузки в браузере (актуально для Chrome/Firefox/Safari 2024-2025 годов). Серверную приёмку, антивирус и хранение обозначаю строкой, но не разворачиваю — это отдельная тема.
Почему fetch не показывает прогресс отправки
Fetch API умеет читать прогресс скачивания через response.body (ReadableStream), но не даёт прогресса на отправку тела запроса. На момент написания в стабильных браузерах нет способа подписаться на события выгрузки через fetch. Спецификация обсуждает ReadableStream в теле запроса с duplex: 'half', но поддержка неполная и на прогресс это напрямую не решает.
Поэтому для прогресса отправки до сих пор используют старый XMLHttpRequest — у него есть upload.onprogress. Это не легаси-костыль, а рабочий выбор на 2025 год.
// Прогресс отправки через XMLHttpRequest
function uploadWithProgress(file, url, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
// Событие приходит по мере отправки тела
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent, event.loaded, event.total);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.responseText);
} else {
reject(new Error('HTTP ' + xhr.status));
}
});
xhr.addEventListener('error', () => reject(new Error('Сетевая ошибка')));
xhr.addEventListener('timeout', () => reject(new Error('Таймаут')));
const form = new FormData();
form.append('file', file);
xhr.send(form);
});
}
Важная деталь: event.total в upload.onprogress — это размер тела запроса вместе с multipart-обвязкой, а не чистый размер файла. Для процентов это несущественно, но если считаете скорость в мегабитах, помните про накладные расходы FormData.
Что показывает прогресс на самом деле
Событие upload.progress считает байты, ушедшие в сокет, а не принятые сервером. При загрузке через обратный прокси (Nginx) браузер может показать 100% раньше, чем сервер дочитал тело — прокси буферизует запрос. Пользователь видит «готово», а приложение ещё ждёт ответ. Не закрывайте UI на 100% прогресса — закрывайте на событии load.
Чек-лист перед продакшеном
- Проверка типа и размера на клиенте — до отправки. Сверяйте
file.sizeиfile.typeперед запросом, чтобы не гнать 900 мегабайт на сервер ради ответа «слишком большой файл». Клиентская проверка — это UX, а не безопасность: сервер обязан проверять то же самое. - Возможность отмены. У
XMLHttpRequestестьxhr.abort(). Дайте кнопку отмены — на большом файле это не роскошь. - Таймаут согласован с сервером и прокси.
xhr.timeout = 0(без таймаута) для больших файлов, иначе браузер оборвёт долгую загрузку. При этом на Nginx проверьтеclient_max_body_sizeиproxy_read_timeout— точные директивы смотрите в документации вашей версии Nginx. - Обработка ошибки сети отдельно от ошибки HTTP. Событие
errorу XHR — это обрыв соединения; неуспешный статус (413, 500) приходит вload. Это разные сценарии для пользователя. - Очистка object URL. Если делаете превью через
URL.createObjectURL(file), вызывайтеURL.revokeObjectURL()после использования — иначе память течёт при множественных загрузках. - Ограничение параллелизма. При загрузке десяти файлов сразу не открывайте десять XHR одновременно — браузер и так лимитирует соединения на домен, но очередь по 3-4 файла даёт предсказуемый прогресс.
Прогресс-бар врёт по умолчанию. Он показывает, сколько байт ушло в сеть, а не сколько принял и обработал сервер. Финал загрузки — это ответ сервера, а не цифра 100%.
Большие файлы: почему нужен chunked upload
При загрузке файла целиком в одном запросе вы упираетесь в три ограничения: лимит тела на прокси, таймаут соединения и невозможность продолжить после обрыва. На мобильной сети файл в гигабайт почти гарантированно оборвётся хотя бы раз, и пользователь начнёт с нуля.
Решение — резать файл на части через Blob.slice() и слать их отдельными запросами. Браузер режет Blob без загрузки в память целиком, поэтому память не переполняется.
// Нарезка файла на части фиксированного размера
async function uploadInChunks(file, url, chunkSize = 5 * 1024 * 1024) {
const totalChunks = Math.ceil(file.size / chunkSize);
for (let index = 0; index < totalChunks; index++) {
const start = index * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end); // не грузит весь файл в память
const form = new FormData();
form.append('chunk', chunk);
form.append('index', String(index));
form.append('total', String(totalChunks));
form.append('filename', file.name);
// Каждую часть шлём отдельным запросом; сервер собирает по index
const response = await fetch(url, { method: 'POST', body: form });
if (!response.ok) {
throw new Error('Часть ' + index + ' не загрузилась: ' + response.status);
}
const percent = Math.round(((index + 1) / totalChunks) * 100);
// здесь обновляем UI
}
}
С chunked-загрузкой прогресс считается по числу отправленных частей — точно и без событий upload.progress. Сборку частей и проверку целостности на сервере здесь не рассматриваю, но именно сервер должен уметь собрать файл по index и вернуть ошибку при пропущенной части.
Сравнение подходов
| Подход | Прогресс отправки | Файлы больше 100 МБ | Возобновление после обрыва | Сложность сервера |
|---|---|---|---|---|
| fetch, файл целиком | Нет | Рискованно | Нет | Минимальная |
| XHR, файл целиком | Да (upload.progress) | Рискованно | Нет | Минимальная |
| fetch/XHR, chunked | По числу частей | Надёжно | Да, если сервер поддерживает | Высокая |
Когда что выбирать
- Файлы до ~20-50 МБ, прогресс нужен — XHR с одним запросом. Проще всего, серверу почти ничего не требуется.
- Прогресс не критичен, файлы мелкие — fetch без прогресс-бара или с индикатором «идёт загрузка» без процентов.
- Файлы в сотни мегабайт и больше, мобильные сети — только chunked с возобновлением. Дороже по серверу, но иначе загрузка не долетит.
Что ломается в проде
- 413 Payload Too Large. Прокси режет тело раньше, чем приложение его увидит. Клиентская проверка размера должна совпадать с лимитом прокси, иначе пользователь узнаёт об отказе после долгой загрузки.
- Прогресс замер на 100%, ответа нет. Буферизация на прокси. Не считайте загрузку успешной по прогрессу — только по ответу сервера.
- Утечка памяти на превью. Забытый
revokeObjectURLпри загрузке пачками фотографий с превью. - CORS на загрузке. Если фронт и API на разных доменах, preflight-запрос и заголовки на multipart легко ломаются — проверяйте не на localhost, а на реальных доменах.
- Двойная отправка. Пользователь жмёт «загрузить» дважды, пока идёт запрос. Блокируйте кнопку на время загрузки и разблокируйте в блоке finally.
Что настроить дальше
После клиентской части закройте серверную: лимит тела на прокси согласуйте с клиентской проверкой, для chunked — реализуйте сборку по index и endpoint статуса, чтобы возобновлять после обрыва. Отдельно посмотрите на прямую загрузку в объектное хранилище (S3-совместимое) через presigned URL — тогда файл минует ваш бэкенд, но прогресс всё равно считается через XHR на клиенте. Мерьте долю оборванных загрузок и время до первого успешного байта на сервере — эти два показателя быстрее всего покажут, что пора переходить на chunked.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: MDN — XMLHttpRequest: upload и события прогресса, MDN — Blob.slice()
Частые вопросы
Почему прогресс-бар на fetch стоит на месте?
А если браузер старый и XHR ведёт себя иначе?
Загрузка отваливается на большом файле, хотя код правильный — что делать?
Какой размер части выбрать для chunked upload?
Нужна ли клиентская проверка типа файла, если сервер всё равно проверяет?
Можно ли отменить загрузку на середине?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.