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.
These examples plug into an existing session-backed application with the gateway contract below.
Run setup and builds from that application’s Yarn 4 project root, with a package.json and a
public/ directory. The commands update dependencies and replace the named built assets; they do
not create or start an application server. Node 26.11.1 and Yarn 4.18.1 are the tested build versions,
not intrinsic minimum versions for browser uploads. Install Yarn separately using its
installation guide. The browser needs module workers,
createImageBitmap, OffscreenCanvas, and AbortSignal.any()/timeout().
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. Unrecovered HTTP failures 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 copied from the input. The JPEG encoder can add its own metadata, such as resolution. Browser color conversion can change colors, including in CMYK JPEGs; inspect the result and 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.
Browser decoding also does not establish that a damaged input is intact. Server-side content validation still applies to the prepared JPEG. Preparation has a 30-second deadline; a slow device can time out on an otherwise eligible image.
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 settled = false
let timer
const finish = (error, blob) => {
if (settled) return
settled = true
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.
The maintained Node.js tus server supplies protocol and
storage implementations; your application’s gateway must supply the session and publication policy.
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:
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn add --exact tus-js-client@4.3.1 &&
yarn add --dev --exact esbuild@0.28.2)
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,
httpStack: {
getName() { return 'SameOriginFetch' },
createRequest(method, url) {
const controller = new AbortController()
const requestHeaders = new Headers()
return {
getMethod() { return method },
getURL() { return url },
getUnderlyingObject() { return controller },
setHeader(name, value) { requestHeaders.set(name, value) },
getHeader(name) { return requestHeaders.get(name) },
setProgressHandler() {},
async abort() { controller.abort() },
async send(body) {
const response = await fetch(url, {
method, body, headers: requestHeaders,
credentials: 'same-origin', redirect: 'error', signal: controller.signal,
})
const text = response.ok ? await response.text() : ''
return {
getStatus() { return response.status },
getHeader(name) { return response.headers.get(name) },
getBody() { return text },
getUnderlyingObject() { return response },
}
},
}
},
},
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. Its small Fetch transport implements the client’s HTTP-stack interface to refuse redirects before following them. Progress advances at tus chunk boundaries rather than tracking bytes in flight. After a lost response, a browser can replay a request before tus reconciles the committed offset; the server must reject a stale offset without appending duplicate bytes. 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. A slow connection can hit this deadline even below 10 MiB. Final publication has a separate 30-second deadline. Cancellation stops the client’s work, but does not prove that the server deleted or withheld an upload; abandoned staging must expire on the server.
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')
const resize = document.getElementById('resize')
let controller = null
button.onclick = async () => {
if (controller || !input.files?.[0]) return
controller = new AbortController()
button.disabled = true
input.disabled = true
resize.disabled = true
document.getElementById('progress').value = 0
status.textContent = 'Preparing upload…'
try {
let file = input.files[0]
if (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
resize.disabled = false
}
}
document.getElementById('cancel').onclick = () => controller?.abort()
Bundle the main entry and serve the worker beside it on the same HTTPS origin:
(command -v node >/dev/null && command -v yarn >/dev/null && command -v cp >/dev/null &&
yarn esbuild main.js --bundle --format=esm --tsconfig-raw='{}' --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:
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn add --exact @uppy/core@6.2.0 @uppy/dashboard@6.0.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 active = null
let destroyed = false
const cancel = () => {
const previous = active
active = null
previous?.controller.abort()
}
uppy.on('cancel-all', cancel)
uppy.on('file-removed', (file) => {
if (active?.fileId === file.id) cancel()
})
uppy.addUploader(async (ids) => {
if (active) throw new Error('An upload is already running.')
const file = uppy.getFile(ids[0])
if (!file || !(file.data instanceof Blob)) throw new Error('Select a local file.')
const operation = { fileId: file.id, controller: new AbortController() }
const uploadStarted = Date.now()
active = operation
const current = () => !destroyed && active === operation && Boolean(uppy.getFile(file.id))
uppy.emit('upload-start', [file])
try {
const id = await uploadFile(file.data, {
owner: document.documentElement.dataset.uploadOwner,
csrf: document.documentElement.dataset.csrf,
signal: operation.controller.signal,
onProgress(value) {
if (!current()) return
uppy.emit('upload-progress', file, {
uploadStarted,
bytesUploaded: Math.floor((file.data.size * value) / 100),
bytesTotal: file.data.size,
})
},
})
if (current()) uppy.emit('upload-success', file, { status: 200, body: { id } })
} catch {
if (!current()) return
uppy.emit('upload-error', file, new Error('Upload not confirmed. Check your uploads.'))
throw new Error('Upload not confirmed.')
} finally {
if (active === operation) active = null
}
})
return () => {
destroyed = true
cancel()
uppy.destroy()
}
}
let cleanup = mountUploader()
window.addEventListener('pagehide', () => cleanup())
window.addEventListener('pageshow', (event) => {
if (event.persisted) cleanup = mountUploader()
})
Use this markup instead of the basic page’s controls and script, with the same session attributes
on <html>:
<link rel="stylesheet" href="/uppy-main.css" />
<div id="drag-drop-area"></div>
<script type="module" src="/uppy-main.js"></script>
Build the alternative entry point:
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn esbuild uppy-main.js --bundle --format=esm --tsconfig-raw='{}' --outfile=public/uppy-main.js)
Cancellation retires the current operation before admitting another file, so late callbacks cannot
mark its replacement as failed or clear its cancellation handle. On pagehide, this integration
destroys the Dashboard and cancels active client work; a persisted restore mounts an empty Dashboard.
Select the file again after returning. Remote-provider uploads need a separate authenticated
Companion integration; this example accepts local Blobs only. See
Uppy’s uploader API.
Confirm the published result
On the basic page, choose a file, optionally enable image preparation, and select Upload. The page waits for a valid server receipt before displaying Upload published.
Uppy’s corresponding state is Complete. A 202 response, failed validation, or timeout leaves the upload unconfirmed even when every byte was sent. Check the authenticated upload list before retrying an uncertain publication; a fresh call creates new staging.
For an unprepared file, compare the stored byte count and SHA-256 with the selected file through your application’s authorized inspection tools. For a prepared image, inspect the resulting JPEG: neither dimension exceeds 1024, its aspect ratio is preserved within rounding, and transparent regions have a white background. Resizing and JPEG encoding can increase the size of a small PNG, so measure the prepared size and visual quality before enabling preparation by default.
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.
