File uploads look simple right up until you ship one.
A button, an endpoint, maybe a progress bar if you care. Your unit tests pass. Your API returns 200. CI is green. Then a real user drags in a 180 MB video from Safari on hotel Wi‑Fi, the presigned URL expires mid-flow, the browser blocks the upload because CORS is half-configured, the UI gets stuck at 100%, they click submit twice, and your support team gets a screenshot that just says “processing…” forever.
This is exactly the kind of workflow where traditional testing gives false confidence. A file upload flow is not correct because one endpoint responds successfully. It’s correct when a user can:
- select or drag a real file
- get a valid upload target
- upload directly to object storage
- see accurate progress
- recover from transient failure
- avoid duplicate submissions
- trigger processing
- and finally see the processed result
That’s the bar.
In this article, we’ll build a complete upload workflow end to end:
- a drag-and-drop frontend in TypeScript
- a backend that issues presigned upload URLs
- direct upload to S3-compatible object storage
- upload progress and retry behavior
- post-upload processing status
- Playwright tests that use a real browser and real files
- CI/CD configuration that verifies the full path instead of mocking it away
The stack is intentionally simple:
- Frontend: Vite + TypeScript
- Backend: Node.js + Express + TypeScript
- Storage: AWS S3 or any S3-compatible bucket
- Browser verification: Playwright
The code is designed to be runnable, but the bigger point is architectural: debugging and testing file uploads means verifying the workflow, not just the code paths.
What we’re building
The product flow is straightforward:
- User picks a file via input or drag-and-drop.
- Frontend asks the backend for a presigned upload URL.
- Frontend uploads the file directly to object storage.
- Frontend confirms completion with the backend.
- Backend starts simulated post-upload processing.
- Frontend polls for processing status and shows the final result.
We’ll also handle the failure modes that usually get ignored until production:
- invalid file types
- oversized files
- CORS misconfiguration
- expired presigned URLs
- intermittent upload failures
- double-clicking upload
- progress UI that lies
- successful upload but broken “done” state
Why the usual approach fails
Most teams test file uploads at the wrong level.
They write a backend test that calls POST /upload/presign and asserts that it returns a JSON body. Maybe they write a frontend component test that mocks fetch and checks whether “Upload complete” appears. Sometimes they mock the entire storage layer and call it good.
That misses the real system boundaries:
- browser-to-backend request
- backend-to-storage signing logic
- browser-to-storage upload
- storage CORS behavior
- upload progress reporting
- UI state transitions under retry/failure
- backend processing after upload
That’s why debugging these flows is so painful. Every layer looks healthy in isolation. The integrated path fails.
If your CI/CD pipeline only verifies mocked API responses, you’re certifying a version of the system users never actually run.
Project structure
We’ll use a simple monorepo layout:
txtfile-upload-demo/ apps/ api/ src/ index.ts storage.ts jobs.ts web/ src/ main.ts app.ts styles.css tests/ upload.spec.ts package.json pnpm-workspace.yaml playwright.config.ts docker-compose.yml
You can use npm or pnpm. I’ll show pnpm-style scripts, but nothing here depends on it.
Backend: issue presigned URLs and track upload state
First, install the backend dependencies:
bashpnpm add express cors dotenv @aws-sdk/client-s3 @aws-sdk/s3-request-presigner uuid zod pnpm add -D typescript tsx @types/express @types/node
Create apps/api/src/storage.ts:
tsimport { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; const region = process.env.AWS_REGION!; const bucket = process.env.S3_BUCKET!; export const s3 = new S3Client({ region, endpoint: process.env.S3_ENDPOINT || undefined, forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true', credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, }); export async function createPresignedUploadUrl(params: { key: string; contentType: string; expiresInSeconds?: number; }) { const command = new PutObjectCommand({ Bucket: bucket, Key: params.key, ContentType: params.contentType, }); const url = await getSignedUrl(s3, command, { expiresIn: params.expiresInSeconds ?? 60, }); return { url, key: params.key, bucket }; } export async function objectExists(key: string) { try { await s3.send( new HeadObjectCommand({ Bucket: bucket, Key: key, }) ); return true; } catch { return false; } }
Now create apps/api/src/jobs.ts to simulate post-upload processing:
tstype UploadRecord = { id: string; fileName: string; key: string; status: 'pending_upload' | 'uploaded' | 'processing' | 'complete' | 'failed'; result?: { message: string; previewUrl?: string; }; error?: string; createdAt: number; }; const uploads = new Map<string, UploadRecord>(); export function createUploadRecord(record: UploadRecord) { uploads.set(record.id, record); return record; } export function getUploadRecord(id: string) { return uploads.get(id); } export function updateUploadRecord(id: string, patch: Partial<UploadRecord>) { const current = uploads.get(id); if (!current) return undefined; const next = { ...current, ...patch }; uploads.set(id, next); return next; } export function startProcessing(id: string) { const current = uploads.get(id); if (!current) return; updateUploadRecord(id, { status: 'processing' }); setTimeout(() => { const record = uploads.get(id); if (!record) return; updateUploadRecord(id, { status: 'complete', result: { message: `Processed ${record.fileName} successfully`, }, }); }, 2500); }
This uses in-memory state for clarity. In production, this record belongs in a database, and processing should be triggered by a queue or object-created event.
Now the API itself in apps/api/src/index.ts:
tsimport 'dotenv/config'; import express from 'express'; import cors from 'cors'; import { randomUUID } from 'crypto'; import { z } from 'zod'; import { createPresignedUploadUrl, objectExists } from './storage'; import { createUploadRecord, getUploadRecord, startProcessing, updateUploadRecord, } from './jobs'; const app = express(); app.use(cors({ origin: process.env.WEB_ORIGIN?.split(',') || true })); app.use(express.json()); const presignSchema = z.object({ fileName: z.string().min(1), contentType: z.string().min(1), size: z.number().positive().max(250 * 1024 * 1024), }); const allowedTypes = new Set([ 'image/png', 'image/jpeg', 'application/pdf', 'text/plain', ]); app.post('/uploads/presign', async (req, res) => { const parsed = presignSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: 'Invalid request payload' }); } const { fileName, contentType } = parsed.data; if (!allowedTypes.has(contentType)) { return res.status(400).json({ error: 'Unsupported file type' }); } const id = randomUUID(); const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, '_'); const key = `uploads/${id}/${safeName}`; const signed = await createPresignedUploadUrl({ key, contentType, expiresInSeconds: 60, }); createUploadRecord({ id, fileName, key, status: 'pending_upload', createdAt: Date.now(), }); return res.json({ uploadId: id, uploadUrl: signed.url, objectKey: key, expiresIn: 60, }); }); app.post('/uploads/:id/complete', async (req, res) => { const record = getUploadRecord(req.params.id); if (!record) { return res.status(404).json({ error: 'Upload not found' }); } const exists = await objectExists(record.key); if (!exists) { return res.status(409).json({ error: 'Uploaded object not found in storage yet' }); } updateUploadRecord(record.id, { status: 'uploaded' }); startProcessing(record.id); return res.json({ ok: true, status: 'processing' }); }); app.get('/uploads/:id/status', (req, res) => { const record = getUploadRecord(req.params.id); if (!record) { return res.status(404).json({ error: 'Upload not found' }); } return res.json({ id: record.id, fileName: record.fileName, status: record.status, result: record.result, error: record.error, }); }); const port = Number(process.env.PORT || 4000); app.listen(port, () => { console.log(`API listening on http://localhost:${port}`); });
There are a few details here worth calling out:
- We validate file metadata up front.
- We limit file size before issuing presigned URLs.
- We generate server-side object keys instead of trusting the client.
- We verify the object exists before marking the upload complete.
That last one matters. Without it, your frontend can claim success even if the storage upload silently failed.
Frontend: a real upload UI with progress and retries
Now let’s build the browser side.
Install frontend dependencies:
bashpnpm add axios pnpm add -D vite typescript
We’ll use XMLHttpRequest for upload progress because it gives reliable progress events in the browser for this use case. fetch still isn’t the best option for upload progress across environments.
Create apps/web/src/app.ts:
tstype UploadState = | 'idle' | 'requesting_url' | 'uploading' | 'retrying' | 'finishing' | 'processing' | 'complete' | 'error'; const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:4000'; const MAX_RETRIES = 2; export function mountApp(root: HTMLElement) { root.innerHTML = ` <div class="container"> <h1>Upload a file</h1> <div id="dropzone" class="dropzone"> <input id="fileInput" type="file" hidden /> <p>Drag and drop a file here or <button id="browseBtn" type="button">browse</button></p> </div> <div id="fileInfo"></div> <div id="status"></div> <div class="progress-shell"><div id="progressBar" class="progress-bar"></div></div> <div class="actions"> <button id="uploadBtn" type="button" disabled>Upload</button> </div> </div> `; const fileInput = root.querySelector<HTMLInputElement>('#fileInput')!; const browseBtn = root.querySelector<HTMLButtonElement>('#browseBtn')!; const uploadBtn = root.querySelector<HTMLButtonElement>('#uploadBtn')!; const dropzone = root.querySelector<HTMLDivElement>('#dropzone')!; const fileInfo = root.querySelector<HTMLDivElement>('#fileInfo')!; const status = root.querySelector<HTMLDivElement>('#status')!; const progressBar = root.querySelector<HTMLDivElement>('#progressBar')!; let selectedFile: File | null = null; let currentState: UploadState = 'idle'; let activeUpload = false; function setState(state: UploadState, message: string) { currentState = state; status.textContent = message; uploadBtn.disabled = !selectedFile || activeUpload; } function setProgress(percent: number) { progressBar.style.width = `${Math.max(0, Math.min(100, percent))}%`; } function renderFile(file: File | null) { selectedFile = file; fileInfo.textContent = file ? `${file.name} — ${(file.size / 1024 / 1024).toFixed(2)} MB — ${file.type || 'unknown type'}` : ''; uploadBtn.disabled = !file || activeUpload; } async function presign(file: File) { const res = await fetch(`${API_BASE}/uploads/presign`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: file.name, contentType: file.type || 'application/octet-stream', size: file.size, }), }); if (!res.ok) { const error = await res.json().catch(() => ({})); throw new Error(error.error || 'Failed to create upload URL'); } return res.json() as Promise<{ uploadId: string; uploadUrl: string; objectKey: string; expiresIn: number; }>; } function uploadWithProgress(url: string, file: File) { return new Promise<void>((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', url); xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream'); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { setProgress((event.loaded / event.total) * 100); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { setProgress(100); resolve(); } else { reject(new Error(`Upload failed with status ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error('Network error during upload')); xhr.ontimeout = () => reject(new Error('Upload timed out')); xhr.timeout = 30_000; xhr.send(file); }); } async function markComplete(uploadId: string) { const res = await fetch(`${API_BASE}/uploads/${uploadId}/complete`, { method: 'POST', }); if (!res.ok) { const error = await res.json().catch(() => ({})); throw new Error(error.error || 'Failed to finalize upload'); } } async function pollStatus(uploadId: string) { for (;;) { const res = await fetch(`${API_BASE}/uploads/${uploadId}/status`); if (!res.ok) throw new Error('Failed to fetch processing status'); const data = await res.json(); if (data.status === 'complete') { setState('complete', data.result?.message || 'Processing complete'); return; } if (data.status === 'failed') { throw new Error(data.error || 'Processing failed'); } setState('processing', `Processing: ${data.status}`); await new Promise((r) => setTimeout(r, 1000)); } } async function uploadFile(file: File) { if (activeUpload) return; activeUpload = true; uploadBtn.disabled = true; setProgress(0); try { setState('requesting_url', 'Requesting upload URL...'); const signed = await presign(file); let attempt = 0; while (true) { try { setState(attempt === 0 ? 'uploading' : 'retrying', attempt === 0 ? 'Uploading...' : `Retrying upload (${attempt})...`); await uploadWithProgress(signed.uploadUrl, file); break; } catch (err) { attempt += 1; if (attempt > MAX_RETRIES) throw err; await new Promise((r) => setTimeout(r, attempt * 1000)); } } setState('finishing', 'Finalizing upload...'); await markComplete(signed.uploadId); setState('processing', 'Upload complete. Processing file...'); await pollStatus(signed.uploadId); } catch (err) { const message = err instanceof Error ? err.message : 'Upload failed'; setState('error', message); } finally { activeUpload = false; uploadBtn.disabled = !selectedFile; } } browseBtn.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', () => renderFile(fileInput.files?.[0] || null)); dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); }); dropzone.addEventListener('dragleave', () => { dropzone.classList.remove('dragover'); }); dropzone.addEventListener('drop', (e) => { e.preventDefault(); dropzone.classList.remove('dragover'); const file = e.dataTransfer?.files?.[0] || null; renderFile(file); }); uploadBtn.addEventListener('click', () => { if (selectedFile) uploadFile(selectedFile); }); setState('idle', 'Choose a file to begin'); }
And apps/web/src/main.ts:
tsimport { mountApp } from './app'; import './styles.css'; mountApp(document.querySelector('#app')!);
Basic styles in apps/web/src/styles.css:
cssbody { font-family: Inter, system-ui, sans-serif; margin: 0; background: #f7f7f7; color: #111; } .container { max-width: 700px; margin: 60px auto; background: white; padding: 24px; border-radius: 12px; } .dropzone { border: 2px dashed #aaa; border-radius: 12px; padding: 32px; text-align: center; background: #fafafa; } .dropzone.dragover { border-color: #2563eb; background: #eff6ff; } .progress-shell { margin-top: 16px; width: 100%; height: 12px; border-radius: 999px; background: #e5e7eb; overflow: hidden; } .progress-bar { height: 100%; width: 0; background: #16a34a; transition: width 0.15s ease; } .actions { margin-top: 16px; } button { cursor: pointer; }
This is enough for a real working UI, but what matters are the state boundaries:
- requesting URL
- uploading
- retrying
- finalizing
- processing
- complete
- error
If you compress all of that into “loading” and “done,” users won’t know what failed, and debugging becomes guesswork.
The object storage gotcha most teams hit: CORS
This is where uploads often “work on my machine” but fail in browsers.
The backend can generate a perfectly valid presigned URL, and your CLI can upload the file. That does not mean the browser can.
For S3, configure bucket CORS explicitly. Example:
json[ { "AllowedHeaders": ["*"], "AllowedMethods": ["PUT", "GET", "HEAD"], "AllowedOrigins": ["http://localhost:5173"], "ExposeHeaders": ["ETag"] } ]
If you skip this, unit tests won’t save you. Mocked requests won’t save you. Your backend tests won’t save you. Only a real browser upload will tell you the truth.
Handling expired presigned URLs
A subtle production bug: presigned URLs are often too short-lived for large files or interrupted users.
In this demo, URLs expire after 60 seconds. That’s intentionally short to force the issue into view. If a user pauses before clicking upload, or if the network is slow, the PUT can fail with a 403-like signature error.
A more robust approach is:
- keep expiry short enough for security
- request the URL right before upload
- on signature expiry, request a fresh URL and retry
- avoid reusing stale upload state in the UI
In production, I’d distinguish retryable upload errors from terminal ones. For example:
tsfunction shouldRefreshPresign(error: Error) { return /403|signature|expired/i.test(error.message); }
Then your upload loop can re-presign if needed rather than retrying the same dead URL.
Preventing double-submit bugs
This is another issue that passes unit tests and still hurts real users.
Without guarding the upload action, users can:
- click Upload twice
- trigger two presign requests
- upload the same file twice
- get two processing records
- see inconsistent completion states
We already added a simple activeUpload guard and disabled the button during upload. That covers the common path. In more complex apps, also make backend completion idempotent.
An idempotency-minded completion endpoint might:
- accept a client token
- check whether the upload is already finalized
- return the current state instead of duplicating work
That’s not overengineering. It’s basic reliability for a high-friction workflow.
What breaks silently if you skip end-to-end verification
Here’s the practical list.
1. Presigned URL generation is correct, but browser upload fails
Typical causes:
- missing bucket CORS
- mismatched
Content-Type - unsupported headers in the signed request
- path-style vs virtual-host-style S3 config issues
Backend tests will still be green.
2. Progress bar reaches 100%, but the file isn’t usable
This happens when the client treats transfer completion as business completion.
Upload finished to storage? Great. That says nothing about:
- object replication/availability timing
- backend completion registration
- downstream processing success
- whether the UI ever transitions to a usable success state
Users care about “your file is ready,” not “some bytes were accepted.”
3. Large files behave differently than tiny fixtures
A 5 KB mocked test file proves almost nothing.
Larger files surface:
- timeout behavior
- progress event reliability
- retry timing
- memory pressure in the UI
- expired URL windows
4. Retry logic doesn’t actually work
Many teams “have retries” on paper. In practice they:
- retry the wrong step
- retry non-idempotent actions
- retry with the same expired URL
- never reset state correctly after failure
5. Processing succeeded, but the user never sees it
A classic distributed-system bug: backend state is correct, frontend polling/rendering is not.
That’s still a broken feature.
Real browser verification with Playwright
Now let’s test the thing that users actually do.
Install Playwright:
bashpnpm add -D @playwright/test pnpm exec playwright install --with-deps
Create playwright.config.ts:
tsimport { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', timeout: 60_000, use: { baseURL: 'http://localhost:5173', trace: 'retain-on-failure', video: 'retain-on-failure', }, webServer: [ { command: 'pnpm --filter api dev', port: 4000, reuseExistingServer: !process.env.CI, }, { command: 'pnpm --filter web dev --host 0.0.0.0', port: 5173, reuseExistingServer: !process.env.CI, }, ], });
Now create tests/upload.spec.ts:
tsimport { test, expect } from '@playwright/test'; import path from 'path'; import fs from 'fs'; function ensureFixture(name: string, sizeBytes: number) { const dir = path.join(process.cwd(), 'tests', 'fixtures'); fs.mkdirSync(dir, { recursive: true }); const filePath = path.join(dir, name); if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, Buffer.alloc(sizeBytes, 'a')); } return filePath; } test('uploads a real file and shows processed result', async ({ page }) => { const filePath = ensureFixture('sample.txt', 256 * 1024); await page.goto('/'); await page.setInputFiles('#fileInput', filePath); await expect(page.locator('#fileInfo')).toContainText('sample.txt'); await page.getByRole('button', { name: 'Upload' }).click(); await expect(page.locator('#status')).toContainText(/Requesting upload URL|Uploading|Finalizing upload|Processing/); await expect(page.locator('#status')).toContainText('Processed sample.txt successfully', { timeout: 20_000, }); }); test('prevents double-submit during upload', async ({ page }) => { const filePath = ensureFixture('double-submit.txt', 128 * 1024); await page.goto('/'); await page.setInputFiles('#fileInput', filePath); const uploadButton = page.getByRole('button', { name: 'Upload' }); await uploadButton.click(); await expect(uploadButton).toBeDisabled(); }); test('rejects unsupported file types with a visible error', async ({ page }) => { const dir = path.join(process.cwd(), 'tests', 'fixtures'); fs.mkdirSync(dir, { recursive: true }); const filePath = path.join(dir, 'malware.exe'); fs.writeFileSync(filePath, Buffer.alloc(1024, 'b')); await page.goto('/'); await page.setInputFiles('#fileInput', { name: 'malware.exe', mimeType: 'application/x-msdownload', buffer: fs.readFileSync(filePath), }); await page.getByRole('button', { name: 'Upload' }).click(); await expect(page.locator('#status')).toContainText('Unsupported file type'); });
These tests matter because they verify real browser behavior:
- file input selection
- actual HTTP requests from the page
- real upload path to object storage
- UI transitions users see
That gives you debugging evidence unit tests can’t.
Testing retry behavior without lying to yourself
Retry logic is valuable, but easy to fake in tests. If you just mock uploadWithProgress to fail once and succeed once, you’ve tested a branch, not the system.
A better approach is to inject a controlled failure mode through the backend or a test-only proxy.
For example, add an API toggle in non-production mode:
tslet failNextUpload = false; app.post('/test/fail-next-upload', (_req, res) => { if (process.env.NODE_ENV === 'production') { return res.status(403).end(); } failNextUpload = true; res.json({ ok: true }); });
Then, in your presign route, generate an invalid URL when toggled, or point the frontend at a test proxy that fails the first PUT. Another option is a local S3-compatible service plus a network proxy that drops the first upload request.
The exact mechanism matters less than the principle: verify retries against a failure that looks like the real world, not just a mocked promise rejection.
CI/CD: prove the flow works in automation
Now put this into CI so your pipeline checks the real workflow.
Here’s a GitHub Actions example using LocalStack for S3-compatible storage so browser tests can run in CI without hitting AWS directly.
Create docker-compose.yml:
yamlversion: '3.8' services: localstack: image: localstack/localstack:latest ports: - '4566:4566' environment: - SERVICES=s3 - DEBUG=1
A setup script can create the bucket before tests run.
Example scripts/setup-bucket.mjs:
jsimport { S3Client, CreateBucketCommand, PutBucketCorsCommand } from '@aws-sdk/client-s3'; const client = new S3Client({ region: 'us-east-1', endpoint: 'http://localhost:4566', forcePathStyle: true, credentials: { accessKeyId: 'test', secretAccessKey: 'test', }, }); const Bucket = 'uploads'; await client.send(new CreateBucketCommand({ Bucket })).catch(() => {}); await client.send( new PutBucketCorsCommand({ Bucket, CORSConfiguration: { CORSRules: [ { AllowedHeaders: ['*'], AllowedMethods: ['PUT', 'GET', 'HEAD'], AllowedOrigins: ['http://localhost:5173'], ExposeHeaders: ['ETag'], }, ], }, }) ); console.log('Bucket ready');
Then GitHub Actions in .github/workflows/e2e.yml:
yamlname: e2e-upload-flow on: push: pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - run: docker compose up -d - name: Wait for LocalStack run: | for i in {1..30}; do curl -s http://localhost:4566/_localstack/health && break sleep 2 done - name: Setup bucket and CORS run: node scripts/setup-bucket.mjs - name: Install Playwright browsers run: pnpm exec playwright install --with-deps chromium - name: Run e2e tests env: AWS_REGION: us-east-1 AWS_ACCESS_KEY_ID: test AWS_SECRET_ACCESS_KEY: test S3_BUCKET: uploads S3_ENDPOINT: http://localhost:4566 S3_FORCE_PATH_STYLE: 'true' WEB_ORIGIN: http://localhost:5173 VITE_API_BASE: http://localhost:4000 run: pnpm exec playwright test - name: Upload Playwright artifacts if: always() uses: actions/upload-artifact@v4 with: name: playwright-artifacts path: | playwright-report test-results
This is what useful CI/CD looks like for a workflow like file upload:
- real browser
- real frontend
- real backend
- real object storage behavior
- real CORS
- real file selection
Not a mocked approximation.
Practical debugging tips when uploads fail in production
When this breaks, you need evidence fast.
Here’s the debugging checklist I’ve found most useful.
Capture upload phase separately
Log distinct events for:
- presign requested
- presign returned
- upload started
- upload progress checkpoints
- upload completed
- finalize requested
- processing started
- processing completed
If you only log “upload failed,” you’ve already made debugging harder than it needs to be.
Include upload IDs everywhere
The uploadId should tie together:
- frontend logs
- backend API logs
- storage object key
- processing job logs
Without correlation IDs, distributed workflows become archaeology.
Watch for UI state dead-ends
A lot of file upload bugs are not transport bugs. They’re state-machine bugs.
Examples:
- button remains disabled after failure
- user can retry, but old error never clears
- progress bar shows 100 even after finalize fails
- processing poll continues forever after terminal error
These are exactly the bugs browser automation catches well.
Save artifacts from failed browser tests
Playwright traces and video are not nice-to-haves here. They’re often the shortest path to root cause.
If CI says the upload test failed because the page never showed success, the trace tells you whether:
- the file input wasn’t populated
- the browser hit a CORS error
- the PUT request returned 403
- the finalize call returned 409
- the UI got stuck after processing completed
That’s high-value debugging data.
A better mental model for testing uploads
For this class of feature, think in layers:
Unit tests
Use them for:
- validation rules
- key generation
- utility functions
- status reducer/state machine logic
Good and necessary, but insufficient.
Integration tests
Use them for:
- API request validation
- storage signing logic
- database record transitions
- processing trigger behavior
Also necessary.
Browser end-to-end tests
Use them for the actual user workflow:
- selecting files
- drag-and-drop behavior
- real upload to storage
- progress/status UX
- recovery after failure
- visible success state
This is the layer that protects developer productivity the most, because it catches the expensive bugs before humans have to chase them through staging or production.
Production hardening beyond this demo
This build is intentionally compact. In production, I’d add a few things immediately:
Multipart uploads for large files
For truly large files, use multipart upload rather than a single PUT. That gives you:
- better resilience
- part-level retries
- support for bigger files
- resumability options
Content validation after upload
Don’t trust MIME type alone. Validate server-side after upload:
- inspect file signatures
- run antivirus scanning if required
- enforce product-specific constraints
Explicit retry classes
Separate:
- network/transient errors
- expired URL errors
- validation errors
- processing failures
Users should get different messaging and recovery options depending on the class.
Webhook or event-driven processing
Polling is fine for a demo and acceptable for some products, but event-driven completion is better when processing is longer-lived.
Cleanup for abandoned uploads
Presigned flows create partial states:
- presigned but never uploaded
- uploaded but never finalized
- finalized but processing failed
You need cleanup jobs and operational visibility for these cases.
The core insight
The interesting part of file upload reliability is not generating a presigned URL. It’s not even the storage PUT by itself.
The real problem is orchestrating a messy, multi-step workflow across browser, backend, storage, and processing systems in a way users can actually complete.
That’s why so many teams think they’ve tested uploads when they haven’t.
Their tests prove that functions returned values. Their CI proves that mocks behaved consistently. Neither proves that a browser user can upload a file and reach a correct end state.
If you care about correctness, debugging, testing, CI/CD, and developer productivity, this distinction matters a lot. High-friction workflows are where shallow test coverage burns the most time.
Wrap-up
We built a real upload flow with:
- frontend file selection and drag-and-drop
- backend presigned URL generation
- direct browser upload to object storage
- progress and retry handling
- finalize and processing steps
- Playwright browser verification
- CI automation with S3-compatible storage
But the bigger takeaway is simpler: don’t certify upload workflows based on mocked responses or isolated endpoints.
A file upload feature works when a user can complete the entire journey, including failure recovery and visible completion. Anything less is partial confidence dressed up as testing.
That’s the standard worth holding in modern software, especially now that teams ship more code, faster, through increasingly automated pipelines. The more velocity you have, the more dangerous false confidence becomes.
Test the workflow users actually depend on. That’s where reliability lives.
