How to Implement File Upload with fetch/axios | FormData, Progress Bar, Error Handling
When implementing file upload in modern web applications, it has become standard to send files asynchronously using the <code>fetch</code> API or <code>axios</code>. While simple transmission can be implemented in just a few lines, considering progress bars, error handling, chunking large files, and CORS support requires attention to many factors. This article systematically explains all of these.
How to use FormData (append / set)
<code>FormData</code> is an API for building data in JavaScript equivalent to <code>enctype="multipart/form-data"</code> in HTML forms. It can handle entire form data including not just files but also text fields.
// input[type="file"] からファイルを取得
const fileInput = document.querySelector('#file-input');
const file = fileInput.files[0];
// FormData を作成
const formData = new FormData();
// append: 同名のキーで複数の値を追加できる
formData.append('file', file);
formData.append('file', anotherFile); // 複数ファイルを同じキーで送れる
formData.append('description', 'アップロードテスト');
formData.append('userId', '12345');
// set: 既存のキーがあれば上書き(追加ではなく置換)
formData.set('file', newFile); // 既存の 'file' エントリをすべて削除して置換
// 中身の確認
for (const [key, value] of formData.entries()) {
console.log(key, value);
}
// 複数ファイルを input[type="file" multiple] から追加する場合
const multipleFiles = fileInput.files;
Array.from(multipleFiles).forEach(f => {
formData.append('files[]', f);
});
File Upload with fetch
When sending FormData with <code>fetch</code>, the important thing is <strong>not to manually set the Content-Type header</strong>.
async function uploadWithFetch(file) {
const formData = new FormData();
formData.append('file', file);
formData.append('name', file.name);
// NG: Content-Type を手動で設定してはいけない
// fetch('/upload', {
// method: 'POST',
// headers: { 'Content-Type': 'multipart/form-data' }, // ← これは誤り!
// body: formData,
// });
// OK: Content-Type は fetch が自動で設定する(boundary パラメータも含む)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
// headers は指定しない(または Content-Type 以外のヘッダーのみ指定)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Unknown error' }));
throw new Error(`Upload failed: ${response.status} - ${error.message}`);
}
return response.json();
}
// 使用例
const fileInput = document.querySelector('#file-input');
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const result = await uploadWithFetch(file);
console.log('アップロード成功:', result);
} catch (err) {
console.error('アップロード失敗:', err.message);
}
});
Why you should not manually set Content-Type: <code>multipart/form-data</code> requests require a <code>boundary</code> parameter (e.g., <code>boundary=----WebKitFormBoundaryXXX</code>), which the browser automatically generates based on the FormData. If you manually set <code>Content-Type: multipart/form-data</code>, the boundary will be missing and the server will not be able to parse it.
Progress Display with fetch (ReadableStream)
With the fetch API, response reception progress can be obtained via <code>ReadableStream</code>, but <strong>upload progress cannot be obtained with fetch alone</strong>. If upload progress is needed, use axios or XMLHttpRequest.
// fetch でダウンロード進捗を取得する例(アップロードとは逆方向)
async function downloadWithProgress(url, onProgress) {
const response = await fetch(url);
const contentLength = response.headers.get('Content-Length');
const total = parseInt(contentLength, 10);
let loaded = 0;
const reader = response.body.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
onProgress(loaded, total);
}
return new Blob(chunks);
}
File Upload with axios + Progress Bar Implementation with onUploadProgress
axios can retrieve upload progress via the <code>onUploadProgress</code> callback. Since it wraps XMLHttpRequest, you can utilize the browser's upload progress events.
import axios from 'axios';
async function uploadWithAxios(file, onProgress) {
const formData = new FormData();
formData.append('file', file);
const response = await axios.post('/api/upload', formData, {
headers: {
// axios は FormData を自動検出して Content-Type を設定するが、
// 明示的に undefined を指定して axios に任せることもできる
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const percent = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
onProgress(percent);
}
},
// タイムアウト設定(ミリ秒)
timeout: 300000, // 5分
});
return response.data;
}
// プログレスバーと組み合わせた使用例
const progressBar = document.querySelector('#progress-bar');
const progressText = document.querySelector('#progress-text');
async function handleUpload(file) {
try {
const result = await uploadWithAxios(file, (percent) => {
progressBar.style.width = `${percent}%`;
progressBar.setAttribute('aria-valuenow', percent);
progressText.textContent = `${percent}%`;
});
console.log('完了:', result);
} catch (err) {
if (axios.isAxiosError(err)) {
console.error('ステータス:', err.response?.status);
console.error('メッセージ:', err.response?.data?.message);
} else {
console.error('予期しないエラー:', err);
}
}
}
Chunked upload for large files
For files too large to send in a single request (hundreds of MB to GB), you can split them into chunks (small pieces) and send them separately. The server receives each chunk and combines them at the end.
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MiB ずつ分割
async function uploadInChunks(file, onProgress) {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const uploadId = crypto.randomUUID(); // アップロードセッションID
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('uploadId', uploadId);
formData.append('chunkIndex', String(i));
formData.append('totalChunks', String(totalChunks));
formData.append('filename', file.name);
await fetch('/api/upload/chunk', {
method: 'POST',
body: formData,
});
// 各チャンク送信後に進捗を更新
const percent = Math.round(((i + 1) / totalChunks) * 100);
onProgress(percent);
}
// 全チャンク送信後にサーバーへ結合を指示
const response = await fetch('/api/upload/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId, filename: file.name }),
});
return response.json();
}
Error handling (413 / network error / timeout)
Handle errors that can occur during file upload by pattern.
async function robustUpload(file) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
signal: AbortSignal.timeout(60000), // 60秒でタイムアウト
});
// ステータスコード別のエラー処理
if (response.status === 413) {
throw new Error('ファイルサイズが大きすぎます。サーバーの上限を超えています。');
}
if (response.status === 415) {
throw new Error('サポートされていないファイル形式です。');
}
if (response.status === 422) {
const data = await response.json();
throw new Error(`バリデーションエラー: ${data.message}`);
}
if (!response.ok) {
throw new Error(`サーバーエラー: ${response.status} ${response.statusText}`);
}
return await response.json();
} catch (err) {
if (err.name === 'AbortError' || err.name === 'TimeoutError') {
throw new Error('アップロードがタイムアウトしました。ネットワーク接続を確認してください。');
}
if (err instanceof TypeError && err.message.includes('Failed to fetch')) {
throw new Error('ネットワークエラーが発生しました。接続を確認してください。');
}
throw err; // それ以外は再スロー
}
}
CORS Configuration Points
When uploading to an API on a different origin (domain or port), CORS (Cross-Origin Resource Sharing) configuration is required. POST requests containing multipart/form-data are not "simple requests" and will trigger a preflight request (OPTIONS method).
// フロントエンド側:特別な設定は不要(ブラウザが自動でプリフライトを送信)
const response = await fetch('https://api.example.com/upload', {
method: 'POST',
body: formData,
// credentials: 'include', // クッキーを含む場合(サーバー側で credentials: true が必要)
});
# Nginx でのCORS設定例
location /api/upload {
# プリフライトリクエスト(OPTIONS)への応答
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://frontend.example.com';
add_header 'Access-Control-Allow-Methods' 'POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type';
add_header 'Access-Control-Max-Age' '86400';
add_header 'Content-Length' '0';
return 204;
}
add_header 'Access-Control-Allow-Origin' 'https://frontend.example.com';
proxy_pass http://app_backend;
}
Points requiring special attention to CORS regarding file uploads:
- including <code>Content-Type</code> in <code>Access-Control-Allow-Headers</code> can lead to misunderstanding that the browser can set Content-Type freely
- With FormData, preflight requests may not occur because the Content-Type header is not custom-set
- When sending an authentication token in the <code>Authorization</code> header, a preflight request always occurs.
Test files for this article (free)
- → <a href="/ja/files/threshold/" class="text-primary-600 dark:text-primary-400 hover:underline">Boundary value test file list</a> — Verify fetch/axios behavior with various file sizes
- → <a href="/ja/files/images/" class="text-primary-600 dark:text-primary-400 hover:underline">Test Images List</a> — Test uploads with various image formats including JPEG, PNG, and WebP
Related articles
- → <a href="/ja/blog/php-file-upload/" class="text-primary-600 dark:text-primary-400 hover:underline">How to Implement File Upload in PHP | Complete Guide to Validation, Storage, and Security</a>
- → <a href="/ja/blog/multipart-form-data-overhead/" class="text-primary-600 dark:text-primary-400 hover:underline">Calculating multipart/form-data Overhead Accurately</a>
- → <a href="/ja/blog/file-validation-checklist/" class="text-primary-600 dark:text-primary-400 hover:underline">Web Form File Validation Implementation Checklist</a>