Завантаження файлів виглядає вирішеною проблемою. Це sleeper-складна. Дефолтний browser file picker плюс базовий POST-запит обробляє happy path: малий файл, хороше з'єднання, без помилок. Провалюється скрізь інде.
Користувач у поїзді, що завантажує 200MB-відео, коли поїзд заїжджає у тунель. Користувач, що тягне папку, думаючи, що тягне файл. Користувач, що завантажує PDF, який насправді вірус. Користувач, що закрив таб посередині. Користувач на iOS, де файл — це HEIC-фото, яке Safari заявляє як JPEG.
Цей пост — про речі, що виглядають опційними і не є. Паттерни, що обробляють реальних користувачів без того, щоб розробник писав чергову систему з нуля.
Що дефолт робить не так
Наївне завантаження файлу:
<input type="file" />
<button>Upload</button>const file = input.files[0];
await fetch('/api/upload', { method: 'POST', body: file });Це працює для файлів під 10MB на стабільному з'єднанні. Вище — або на flaky-мережі — ламається у п'яти різних місцях:
- Жодної індикації прогресу. Користувач не знає, чи воно працює.
- Жодного способу відновитись з провального завантаження. Треба починати заново.
- Сервер має тримати весь файл у пам'яті.
- Файл проходить через ваш сервер, коли міг би йти прямо в storage.
- Жодної валідації понад те, що робить браузер (приблизно нічого).
Для внутрішнього інструмента з п'ятьма юзерами це нормально. Для будь-чого клієнтського — ні.
П'ять речей, що не опційні
1. Progress UI
Користувач має бачити, що завантаження відбувається, як далеко, і скільки лишилось. Без цього він припускає, що система зламана.
Імплементація: XMLHttpRequest з progress events (так, у 2026 XMLHttpRequest усе ще найчистіший спосіб отримати upload progress; fetch() не дає upload progress без streaming workaround).
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
setProgress(percent);
}
});
xhr.open('POST', '/api/upload');
xhr.send(file);Показуйте: процент готовності, MB завантажено / MB всього, оцінений час, що лишився, для файлів понад 10MB. Оцінка рахується з rate за останні кілька секунд; не довіряйте оцінці на основі першої секунди.
2. Resumable uploads для файлів понад ~50MB
Усе понад 50MB має пережити переривання мережі. Користувач не має перезапускати 30-хвилинне завантаження, бо у нього wifi blipnyy.
Стандарт для цього — tus (resumable upload protocol). tus-js-client на фронті, tus-сумісний endpoint на беку (або managed-сервіс типу Uppy + Companion).
Простіша альтернатива: chunked uploads. Розріжте файл на 5MB-чанки на клієнті. Завантажте кожен чанк окремо. Відстежте, які успішні; ретрайте провальні. Після усіх — сервер збирає.
Cloudflare R2, AWS S3 і Google Cloud Storage усі підтримують multipart uploads нативно, що та сама ідея, керована storage-шаром.
3. Direct upload у storage (пропустіть сервер)
Для будь-чого більшого за кілька MB — завантажуйте напряму у storage (S3, R2, GCS) замість через ваш сервер.
Паттерн:
- Клієнт запитує presigned URL з вашого сервера.
- Сервер генерує presigned URL з коротким терміном (15 хвилин) і повертає.
- Клієнт завантажує напряму на той URL.
- Після завантаження клієнт каже серверу, що готово; сервер записує метадані файлу.
Переваги:
- Ваш сервер не тримає файл у пам'яті.
- Upload-bandwidth не йде через ваш сервер.
- Масштабується горизонтально без backpressure.
- Serverless-функція, що генерує URL, швидка і дешева.
Робота з налаштування реальна (генерація presigned URL, post-upload callback, обробка помилок). Але коли налаштовано — обробляє завантаження будь-якого розміру без турботи сервера.
4. Валідація, серверна і серйозна
Три шари валідації, у порядку строгості:
Client-side: атрибут accept file picker-а, базова перевірка розміру. Це UX, не безпека. Користувачі можуть обійти. Не покладайтесь.
Server-side метадані: коли завантаження завершується — верифікуйте розмір файлу, MIME-тип з перспективи сервера, розширення. Відхиляйте при mismatch.
Content sniffing: прочитайте перші кілька байтів файлу і підтвердіть, що збігаються з заявленим типом. .pdf має починатись з %PDF. .jpg має починатись з FF D8 FF. Інструменти типу file-type у Node.js це роблять. Файл, що заявляє JPEG, але ним не є — червоний прапор.
Антивірусне сканування: для будь-якого файлу, який бачитимуть або завантажуватимуть інші користувачі — пропускайте через ClamAV або managed-сервіс (Cloudmersive, VirusTotal API). Це не обговорюється для user-uploaded контенту. 50ms, що це додає — варті.
Порядок має значення. Ловіть очевидні issues на дешевих шарах спочатку. Тільки сканування на віруси робіть на файлах, що пережили перевірки розміру і вмісту.
5. Відновлення помилок
Конкретні помилки і що робити з кожною:
- З'єднання втрачено посеред завантаження: ретрай з експоненційним бекофом. Якщо resumable upload — продовжуйте з останнього успішного чанка.
- Сервер повернув 5xx: ретрай один раз. Якщо знову провалюється — покажіть помилку користувачу з кнопкою «спробувати ще».
- Файл відхилено сервером (4xx): покажіть причину. «File too large» / «Wrong type» / «Failed virus scan». Не показуйте «Upload failed» без деталей.
- Таб закрито під час завантаження: нічого зробити в польоті, але часткове завантаження має чиститись на сервері (orphaned multipart uploads коштують storage).
- Кілька файлів, один провалився: не роллбекайте успішні. Чітко покажіть, який провалився, і дайте користувачу ретрайнути лише його.
Більшість тікетів саппорту «file upload broken» — це насправді «error message був безкорисний». Конкретні помилки з конкретними шляхами відновлення ловлять більшість з них.
Прохід
Робоче налаштування для типового веб-застосунку:
Клієнт:
- File picker з
acceptіmultipleатрибутами, налаштованими відповідно. - На вибір — валідація розміру і типу client-side (лише UX).
- Запитайте presigned URL для кожного файлу з
/api/uploads/presign. - Завантажте кожен файл через chunked PUT-запити напряму у S3/R2.
- Показуйте прогрес на файл, з загальним прогресом для multi-file-вибору.
- На завершенні — POST у
/api/uploads/confirmз upload-ID. - Покажіть success-стани, ретрайте провальні файли окремо.
Сервер:
/api/uploads/presign: валідуйте запит (юзер авторизований, має квоту), згенеруйте presigned URL з 15-хвилинним терміном, поверніть./api/uploads/confirm: верифікуйте, що файл насправді існує у storage, прочитайте перші кілька байтів для content sniffing, додайте задачу антивірусного сканування, запишіть метадані.- Фонова задача для антивірусного сканування: витягніть файл зі storage, проскануйте, помітьте clean/quarantine. Якщо quarantine — нотифікуйте адміна і видаліть зі storage.
Це 300-500 рядків коду всього, плюс налаштування інфри (S3-бакет, presign-secret, віруссканер). Обробляє 99% upload-кейсів включно з болючими edge cases.
Edge cases, варті знання
HEIC-фото з iOS. Коли iOS-користувачі завантажують фото, файл — HEIC за замовчуванням. Ваш код може припускати JPEG/PNG. Або приймайте HEIC і конвертуйте на сервері (з sharp або imagemagick), або детектьте HEIC client-side і конвертуйте перед завантаженням (heic2any у браузері).
Drag-and-drop папок. Більшість браузерів виставляє drag-and-drop папок через API webkitGetAsEntry. Або підтримуйте явно (рекурсуючи через підпапки), або фільтруйте лише файли і показуйте «папки не підтримуються».
File picker на мобілці. Мобільний file picker відрізняється від десктопного. iOS показує камеру, фото-бібліотеку, файли. Тестуйте на реальних пристроях; camera capture path інший за file selection path.
Дуже великі файли (>1GB). Навіть resumable uploads напружуються при цьому розмірі. Скажіть користувачу наперед («Файли понад 1GB можуть зайняти значний час»). Чітко показуйте ETA. Зберігайте прогрес у localStorage, тож closed-tab recovery можливий.
Throttling мережі. Тестуйте upload UI на 1Mbps і 100Kbps. Progress UI має лишатись корисним на повільних швидкостях. Дефолтний UI зазвичай ні.
Чого ми перестали рекомендувати
Паттерни, від яких ми відійшли:
Завантаження напряму на origin-сервер. Був дефолтом. Тепер direct-to-storage для будь-чого клієнтського.
Один POST-запит для файлів понад 50MB. Було прийнятним на стабільних з'єднаннях. Тепер — chunked або resumable upload.
Антивірусне сканування синхронно на сервері. Було просто. Тепер — як фонова задача, щоб тримати upload response швидким.
Зберігання оригінальних імен файлів як є. Імена файлів несуть ризики безпеки (path traversal, проблеми encoding, ліміти довжини). Генеруйте UUID або хеш на завантаженні, зберігайте оригінальне ім'я як метадані.
Чеклист
- Progress UI для будь-якого завантаження понад 1MB.
- Resumable або chunked для будь-якого файлу понад 50MB.
- Direct-to-storage для будь-якого файлу понад 5MB.
- Server-side валідація метаданих (розмір, тип, розширення).
- Content sniffing для security-чутливого вмісту.
- Антивірусне сканування для будь-якого user-shared вмісту.
- Конкретні error-повідомлення зі шляхами відновлення.
- Протестовано на повільних мережах (throttle до 1Mbps).
- Протестовано з HEIC-фото з iOS.
- Протестовано з multi-file drag-and-drop.
Якщо можете відмітити усі десять — ваше завантаження файлів обробляє кейси, що насправді ламають. Більшість команд відвантажують застосунки, що відмічають три.
Якщо будуєте продукт, де завантаження — частина core-флоу, подивіться, як ми працюємо з веб-застосунками. Завантаження файлів — одне з місць, де ми потіємо над деталями.