Optimizing file uploads in web applications
Upload performance includes time spent preparing a file, transferring it, retrying lost requests, and waiting for server validation. This DevTip builds a bounded image-preparation worker and a resumable browser upload integration, with Uppy as an alternative UI. Measure these stages on your own devices and network before choosing compression settings or increasing concurrency.
Common challenges with file uploads
Large files can exhaust memory, slow networks can cause timeouts, and interruptions can leave the browser uncertain whether a request succeeded. A useful UI distinguishes bytes transferred from server acceptance. Security depends on authenticated ownership and server validation, even when the browser rejects obviously unsuitable files first.
Best practices for optimizing file upload performance
1. Use chunked uploads
Use a resumable protocol with explicit offsets rather than posting anonymous file slices. The tus integration below uses 1 MiB chunks and bounded retries. This limits retransmission size; it does not promise higher throughput on every connection. HTTP failures must reject the operation, including failures after the final byte reaches the server.
2. Implement client-side compression
Image resizing can reduce bytes at the cost of CPU, quality and metadata. The optional worker below accepts JPEG or PNG files up to 10 MiB, limits decoded images to 16 million pixels, fits both dimensions into 1024 × 1024, and produces a JPEG. Transparency becomes white and metadata is not preserved. Keep the original when those changes are unacceptable. Decoding can allocate memory before dimensions are known; this is a bounded convenience for normal user photos, not a defense against malicious image decoders.
Create image-worker.js:
self.onmessage = async ({ data: file }) => {
let bitmap
try {
if (
!(file instanceof Blob) ||
file.size === 0 ||
file.size > 10 * 1024 * 1024 ||
!['image/jpeg', 'image/png'].includes(file.type)
) {
throw new Error('Unsupported image')
}
bitmap = await createImageBitmap(file)
if (bitmap.width * bitmap.height > 16_000_000) throw new Error('Image too large')
const scale = Math.min(1, 1024 / bitmap.width, 1024 / bitmap.height)
const width = Math.max(1, Math.round(bitmap.width * scale))
const height = Math.max(1, Math.round(bitmap.height * scale))
const canvas = new OffscreenCanvas(width, height)
const context = canvas.getContext('2d')
if (!context) throw new Error('Canvas unavailable')
context.fillStyle = 'white'
context.fillRect(0, 0, width, height)
context.drawImage(bitmap, 0, 0, width, height)
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.8 })
if (blob.type !== 'image/jpeg' || blob.size === 0) throw new Error('Encoding failed')
self.postMessage({ blob })
} catch {
self.postMessage({ error: 'Image preparation failed.' })
} finally {
bitmap?.close()
}
}
3. Use Web Workers for background processing
Workers are useful here for decoding and encoding. Fetch and Blob slicing do not need a worker per
request. This prepare-image.js wrapper terminates its one worker on success, runtime error,
message decoding error, cancellation, timeout, or a failed postMessage:
export function prepareImage(file, signal) {
signal.throwIfAborted()
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./image-worker.js', import.meta.url), { type: 'module' })
let timer
const finish = (error, blob) => {
clearTimeout(timer)
signal.removeEventListener('abort', abort)
worker.terminate()
if (error) reject(error)
else resolve(new File([blob], 'upload.jpg', { type: 'image/jpeg' }))
}
const abort = () => finish(new Error('Image preparation canceled.'))
signal.addEventListener('abort', abort, { once: true })
timer = setTimeout(() => finish(new Error('Image preparation timed out.')), 30_000)
worker.onerror = () => finish(new Error('Image worker failed.'))
worker.onmessageerror = () => finish(new Error('Invalid worker message.'))
worker.onmessage = ({ data }) => {
if (
!(data?.blob instanceof Blob) ||
data.blob.size === 0 ||
data.blob.type !== 'image/jpeg'
) {
finish(new Error('Image preparation failed.'))
return
}
finish(null, data.blob)
}
try {
worker.postMessage(file)
} catch {
finish(new Error('Could not start image preparation.'))
}
})
}
See the platform documentation for OffscreenCanvas.convertToBlob and Worker.terminate.
Ensuring security during file uploads
1. Validate file types and sizes
The upload functions accept files up to 10 MiB. Client MIME types, names and sizes are untrusted metadata. The server must independently limit bytes, detect supported content, and apply scanning or decoding policy before making an upload available.
2. Use secure file storage
The following browser examples require an existing authenticated, same-origin application with a tus
endpoint at /api/tus/files/. This is a client tutorial; it does not provision that server.
Configure the gateway to validate the session and X-CSRF-TOKEN on mutations and enforce
X-Upload-Owner against the session on every tus request. Enforce a 10 MiB total, 1 MiB chunks,
per-user storage/rate limits, expiration and private staging. Check ownership on creation, HEAD,
PATCH, and any termination requests. Reject redirects and emit only same-origin Location URLs below
/api/tus/files/.
After tus reports completion, POST /api/upload-publications accepts {uploadUrl}. The server must
resolve that URL only against its own owned upload records, never fetch an arbitrary URL. It
verifies completeness and content, then atomically publishes once; duplicate requests return the
same receipt with HTTP 200 or 201. Non-success or 202 is not a publication confirmation. Return
{id} containing an opaque, nonempty receipt ID. Download access must separately authorize the
requesting user. Random filenames alone do not provide access control.
Implementing resumable uploads with Tus
The protocol’s name is tus. Install the tested client version in your browser project:
corepack yarn add --exact tus-js-client@4.3.1
corepack yarn add --dev --exact esbuild@0.27.3
Create upload.js. Pass the current owner ID and CSRF token from your authenticated page; neither
is a Transloadit Auth Secret. This function retries interruptions within one page session. It does
not automatically associate a newly selected file with a previous upload based only on its name. An
aborted or failed upload expires on the server; a new call creates a new upload.
import { Upload } from 'tus-js-client'
export async function uploadFile(file, { owner, csrf, signal, onProgress }) {
signal.throwIfAborted()
if (
!(file instanceof Blob) ||
file.size === 0 ||
file.size > 10 * 1024 * 1024 ||
!owner ||
!csrf
) {
throw new Error('Choose a file between 1 byte and 10 MiB while signed in.')
}
const headers = { 'X-CSRF-TOKEN': csrf, 'X-Upload-Owner': owner }
const uploadUrl = await new Promise((resolve, reject) => {
let settled = false
let timer
const finish = (error, url) => {
if (settled) return
settled = true
clearTimeout(timer)
signal.removeEventListener('abort', abort)
if (error) {
void upload.abort().catch(() => {})
reject(error)
} else resolve(url)
}
const abort = () => finish(new Error('Upload canceled.'))
const upload = new Upload(file, {
endpoint: '/api/tus/files/',
headers,
chunkSize: 1024 * 1024,
storeFingerprintForResuming: false,
retryDelays: [0, 1000, 3000],
onShouldRetry(error) {
const status = error.originalResponse?.getStatus() ?? 0
return status === 0 || status === 409 || status === 423 || status === 429 || status >= 500
},
onBeforeRequest(request) {
const url = new URL(request.getURL(), location.href)
if (url.origin !== location.origin || !url.pathname.startsWith('/api/tus/files/')) {
throw new Error('Unexpected upload URL')
}
},
onProgress(loaded, total) {
try {
onProgress(Math.min(99, Math.floor((100 * loaded) / total)))
} catch {
finish(new Error('Progress display failed.'))
}
},
onError() {
finish(new Error('Upload failed.'))
},
onSuccess() {
if (!upload.url) finish(new Error('Missing upload URL.'))
else finish(null, upload.url)
},
})
signal.addEventListener('abort', abort, { once: true })
timer = setTimeout(() => finish(new Error('Upload timed out.')), 120_000)
try {
upload.start()
} catch {
finish(new Error('Could not start upload.'))
}
})
signal.throwIfAborted()
const response = await fetch('/api/upload-publications', {
method: 'POST',
credentials: 'same-origin',
redirect: 'error',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadUrl }),
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
})
if (!response.ok || ![200, 201].includes(response.status)) {
throw new Error(`Publication not confirmed (HTTP ${response.status}).`)
}
const receipt = await response.json()
if (typeof receipt.id !== 'string' || !receipt.id) throw new Error('Invalid publication receipt.')
onProgress(100)
return receipt.id
}
This uses the real tus-js-client options and callbacks. The two-minute deadline bounds the whole transfer, including retries. A lost publication response is ambiguous: check your server’s upload list before starting another upload.
For a complete page integration, render escaped data-upload-owner and data-csrf attributes on
<html> from your session-backed page, and add:
<label>File <input id="file" type="file" /></label>
<label><input id="resize" type="checkbox" /> Prepare JPEG or PNG as JPEG</label>
<button id="upload" type="button">Upload</button>
<button id="cancel" type="button">Cancel</button>
<progress id="progress" aria-label="Upload progress" max="100" value="0"></progress>
<p id="status" role="status"></p>
<script type="module" src="/main.js"></script>
Create main.js:
import { prepareImage } from './prepare-image.js'
import { uploadFile } from './upload.js'
const input = document.getElementById('file')
const button = document.getElementById('upload')
const status = document.getElementById('status')
let controller = null
button.onclick = async () => {
if (controller || !input.files?.[0]) return
controller = new AbortController()
button.disabled = true
input.disabled = true
status.textContent = 'Preparing upload…'
try {
let file = input.files[0]
if (document.getElementById('resize').checked) {
file = await prepareImage(file, controller.signal)
}
await uploadFile(file, {
owner: document.documentElement.dataset.uploadOwner,
csrf: document.documentElement.dataset.csrf,
signal: controller.signal,
onProgress(value) {
document.getElementById('progress').value = value
},
})
status.textContent = 'Upload published.'
} catch {
status.textContent = 'Upload failed or canceled. Check your uploads before retrying.'
} finally {
controller = null
button.disabled = false
input.disabled = false
}
}
document.getElementById('cancel').onclick = () => controller?.abort()
Bundle the main entry and serve the worker beside it on the same HTTPS origin:
corepack yarn esbuild main.js --bundle --format=esm --outfile=public/main.js
cp image-worker.js public/image-worker.js
Using Uppy for a seamless user experience
Uppy supplies selection, restrictions, progress and cancellation UI. For this gateway, use Uppy’s
custom uploader interface to call the same uploadFile function. This keeps publication part of
success and uses the same deadline, authentication headers and failure handling. This is an
alternative entry point, not an additional uploader to mount beside the previous page.
Install compatible packages:
corepack yarn add --exact @uppy/core@5.2.0 @uppy/dashboard@5.1.1
Create uppy-main.js and mount it on a page with <div id="drag-drop-area"></div> and the same
session attributes. The application must call the returned cleanup function when removing this UI.
import Uppy from '@uppy/core'
import Dashboard from '@uppy/dashboard'
import '@uppy/core/css/style.min.css'
import '@uppy/dashboard/css/style.min.css'
import { uploadFile } from './upload.js'
export function mountUploader() {
const uppy = new Uppy({
restrictions: { maxNumberOfFiles: 1, maxFileSize: 10 * 1024 * 1024, minFileSize: 1 },
}).use(Dashboard, { inline: true, target: '#drag-drop-area' })
let controller = null
uppy.on('cancel-all', () => controller?.abort())
uppy.on('file-removed', () => controller?.abort())
uppy.addUploader(async (ids) => {
const file = uppy.getFile(ids[0])
if (!file || !(file.data instanceof Blob)) throw new Error('Select a local file.')
controller = new AbortController()
uppy.emit('upload-start', [file])
try {
await uploadFile(file.data, {
owner: document.documentElement.dataset.uploadOwner,
csrf: document.documentElement.dataset.csrf,
signal: controller.signal,
onProgress(value) {
uppy.emit('upload-progress', file, {
uploadStarted: Date.now(),
bytesUploaded: Math.floor((file.data.size * value) / 100),
bytesTotal: file.data.size,
})
},
})
uppy.emit('upload-success', file, { status: 200, body: {} })
} catch {
uppy.emit('upload-error', file, new Error('Upload not confirmed. Check your uploads.'))
throw new Error('Upload not confirmed.')
} finally {
controller = null
}
})
return () => {
controller?.abort()
uppy.destroy()
}
}
let cleanup = mountUploader()
window.addEventListener('pagehide', () => cleanup())
window.addEventListener('pageshow', (event) => {
if (event.persisted) cleanup = mountUploader()
})
Bundle uppy-main.js with esbuild as above and include both its generated JavaScript and CSS in
your page. Remote-provider uploads need a separate authenticated Companion integration; this example
accepts local Blobs only. See Uppy’s uploader API.
Conclusion
Start with bounded file sizes, sequential chunks, explicit failure handling and server-confirmed publication. Add image preparation where the quality tradeoff is appropriate. Measure transfer time, preparation time, retries and finalization independently before tuning the settings.
Additional resources
Answers to common questions
How do you confirm an upload? A transfer’s byte count is not enough. Confirm an authenticated server receipt after validation and publication. Provider-specific malware analysis is a separate integration; it has no universal upload-verification CLI command.
What is an unrestricted file upload? It is an upload endpoint that accepts content without adequate constraints, potentially allowing dangerous files to be stored or executed. Validate on the server and isolate storage from executable web content.
Which HTML form encoding supports file uploads? A traditional form uses method="post" and
enctype="multipart/form-data", with a named file input. When sending FormData with Fetch or
Axios in the browser, let the browser set the multipart boundary; do not invent the header yourself.
