File uploads look easy right up until real users touch them.
The demo works on localhost. The unit tests pass. CI/CD is green. Then production starts producing the usual mess: uploads hang at 98%, Safari refuses the request because of CORS, a presigned URL expires mid-transfer, a user drags the same file in twice, the backend marks an upload as complete before the object is actually readable, and your "processing finished" webhook quietly fails with no visible symptom except a support ticket three hours later.
This is exactly the kind of feature where traditional testing gives false confidence. A mocked fetch call and a few backend tests do not tell you whether a browser can actually stream a file to cloud storage, whether progress events fire, whether retries behave sanely on flaky networks, or whether the full workflow completes all the way from drag-and-drop to post-upload processing.
If you care about debugging, testing, CI/CD, and developer productivity, file upload flows are a great reality check. They force you to test user workflows, not just code paths.
In this article, we’ll build a modern file upload flow end to end:
- a frontend drag-and-drop uploader
- a backend endpoint that creates presigned upload URLs
- direct upload to S3-compatible storage
- progress UI
- retry on failure and expired URLs
- duplicate submission protection
- post-upload processing callback
- Playwright tests that upload real files in a browser
- CI config that proves the whole thing works beyond mocked unit tests
The code examples use TypeScript with Express on the backend and plain browser-side TypeScript on the frontend. The same patterns apply if you use Next.js, FastAPI, Rails, Go, or something else.
What we’re building
The upload flow looks like this:
- User drags a file into the browser UI.
- Frontend asks the backend for an upload session.
- Backend validates intent and returns a presigned PUT URL plus metadata.
- Browser uploads the file directly to object storage.
- Frontend shows upload progress.
- If upload fails because of network or URL expiry, frontend retries intelligently.
- After the upload succeeds, frontend calls the backend to finalize.
- Backend verifies the object exists, marks the upload complete, and queues post-upload processing.
- Processing updates the upload record.
- UI polls for final processing status.
That flow matters because many broken implementations stop at step 4 and assume success. That is not enough. Users don’t care that bytes probably reached a bucket. They care that the file is available and the app actually did what it promised.
Why upload flows break even when tests pass
Before code, it’s worth being explicit about failure modes. These are not edge cases. They are normal cases in production.
1. CORS breaks browser uploads
Your backend test can generate a presigned URL correctly and still fail in the browser because the bucket does not allow the browser’s origin, method, or headers.
2. Presigned URLs expire mid-flow
AI-generated code often requests a short-lived URL, then performs validation, image compression, or user confirmation before uploading. By the time the upload starts, the URL is stale.
3. Progress indicators lie or never update
fetch() is convenient, but upload progress support is still awkward. Many implementations show fake progress bars disconnected from actual browser events.
4. Retries create duplicate records
Users click twice. The network flakes. Your frontend retries. Without idempotency, one user action becomes multiple upload records, multiple processing jobs, and inconsistent UI state.
5. "Upload complete" does not mean "workflow complete"
The object might exist, but your thumbnail generation, virus scan, parsing, or indexing step can fail after upload. If your tests stop early, you miss the thing users actually depend on.
6. CI/CD rarely covers browser-to-storage behavior
Most pipelines run unit tests and maybe API tests. They do not run a real browser against storage with actual CORS, real file inputs, and simulated network interruptions.
That gap is why this article focuses on browser-level verification.
Architecture and project structure
We’ll keep the architecture small and runnable.
txtfile-upload-demo/ backend/ src/ server.ts storage.ts db.ts processing.ts frontend/ index.html app.ts styles.css tests/ upload.spec.ts docker-compose.yml playwright.config.ts package.json
We’ll assume an S3-compatible object store. AWS S3 works. MinIO is great for local and CI. I’ll use MinIO locally because it makes browser-level testing practical.
Backend: create upload sessions and finalize safely
We need a backend that does four things well:
- issues presigned upload URLs
- tracks upload state
- makes finalize idempotent
- verifies object existence before processing
Backend dependencies
bashnpm install express cors zod uuid @aws-sdk/client-s3 @aws-sdk/s3-request-presigner npm install -D typescript tsx @types/express @types/node
In-memory database for the example
Use Postgres in a real app. For the article, an in-memory store keeps the code focused.
ts// backend/src/db.ts export type UploadStatus = | 'created' | 'uploading' | 'uploaded' | 'processing' | 'processed' | 'failed'; export interface UploadRecord { id: string; userId: string; fileName: string; contentType: string; size: number; objectKey: string; status: UploadStatus; createdAt: number; updatedAt: number; etag?: string; error?: string; idempotencyKey?: string; } const uploads = new Map<string, UploadRecord>(); const uploadsByIdempotency = new Map<string, string>(); export const db = { createUpload(record: UploadRecord) { uploads.set(record.id, record); if (record.idempotencyKey) { uploadsByIdempotency.set(record.idempotencyKey, record.id); } return record; }, getUpload(id: string) { return uploads.get(id); }, getByIdempotencyKey(key: string) { const id = uploadsByIdempotency.get(key); return id ? uploads.get(id) : undefined; }, updateUpload(id: string, patch: Partial<UploadRecord>) { const existing = uploads.get(id); if (!existing) return undefined; const next = { ...existing, ...patch, updatedAt: Date.now() }; uploads.set(id, next); return next; }, };
S3 client and presigned URL generation
ts// backend/src/storage.ts import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; const bucket = process.env.S3_BUCKET!; export const s3 = new S3Client({ region: process.env.S3_REGION || 'us-east-1', endpoint: process.env.S3_ENDPOINT, forcePathStyle: true, credentials: { accessKeyId: process.env.S3_ACCESS_KEY!, secretAccessKey: process.env.S3_SECRET_KEY!, }, }); export async function createPresignedUploadUrl(objectKey: string, contentType: string) { const command = new PutObjectCommand({ Bucket: bucket, Key: objectKey, ContentType: contentType, }); const url = await getSignedUrl(s3, command, { expiresIn: 60, }); return url; } export async function objectExists(objectKey: string) { try { const res = await s3.send( new HeadObjectCommand({ Bucket: bucket, Key: objectKey, }) ); return { exists: true, etag: res.ETag, contentLength: res.ContentLength, }; } catch { return { exists: false }; } }
A 60-second expiry is intentionally short for demo purposes. That makes retry logic and browser-level testing more meaningful.
Fake processing job
ts// backend/src/processing.ts import { db } from './db'; export async function enqueueProcessing(uploadId: string) { db.updateUpload(uploadId, { status: 'processing' }); setTimeout(() => { const upload = db.getUpload(uploadId); if (!upload) return; if (upload.fileName.endsWith('.bad.csv')) { db.updateUpload(uploadId, { status: 'failed', error: 'Processing failed: invalid CSV format', }); return; } db.updateUpload(uploadId, { status: 'processed' }); }, 1500); }
Express server
ts// backend/src/server.ts import express from 'express'; import cors from 'cors'; import { z } from 'zod'; import { v4 as uuidv4 } from 'uuid'; import { db } from './db'; import { createPresignedUploadUrl, objectExists } from './storage'; import { enqueueProcessing } from './processing'; const app = express(); app.use(cors({ origin: 'http://localhost:3000' })); app.use(express.json()); const createUploadSchema = z.object({ fileName: z.string().min(1), contentType: z.string().min(1), size: z.number().positive().max(50 * 1024 * 1024), idempotencyKey: z.string().min(8), }); app.post('/uploads', async (req, res) => { const parsed = createUploadSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: parsed.error.flatten() }); } const { fileName, contentType, size, idempotencyKey } = parsed.data; const existing = db.getByIdempotencyKey(idempotencyKey); if (existing) { const uploadUrl = await createPresignedUploadUrl(existing.objectKey, existing.contentType); return res.json({ uploadId: existing.id, objectKey: existing.objectKey, uploadUrl, status: existing.status, reused: true, }); } const uploadId = uuidv4(); const objectKey = `uploads/${uploadId}/${fileName}`; const record = db.createUpload({ id: uploadId, userId: 'demo-user', fileName, contentType, size, objectKey, status: 'created', createdAt: Date.now(), updatedAt: Date.now(), idempotencyKey, }); const uploadUrl = await createPresignedUploadUrl(objectKey, contentType); return res.status(201).json({ uploadId: record.id, objectKey, uploadUrl, status: record.status, reused: false, }); }); app.post('/uploads/:id/finalize', async (req, res) => { const upload = db.getUpload(req.params.id); if (!upload) { return res.status(404).json({ error: 'Upload not found' }); } if (upload.status === 'processing' || upload.status === 'processed') { return res.json({ ok: true, status: upload.status, alreadyFinalized: true }); } const head = await objectExists(upload.objectKey); if (!head.exists) { return res.status(409).json({ error: 'Object not found in storage yet' }); } db.updateUpload(upload.id, { status: 'uploaded', etag: head.etag, }); await enqueueProcessing(upload.id); return res.json({ ok: true, status: 'processing' }); }); app.get('/uploads/:id', (req, res) => { const upload = db.getUpload(req.params.id); if (!upload) { return res.status(404).json({ error: 'Upload not found' }); } return res.json(upload); }); const port = process.env.PORT || 4000; app.listen(port, () => { console.log(`Backend listening on ${port}`); });
There are a few non-negotiable details here:
idempotencyKeyprevents duplicate records when the same action retries.finalizechecks object storage before marking success.finalizeis idempotent too.- processing is modeled as a distinct phase rather than pretending upload completion means business completion.
That separation makes debugging much easier.
Frontend: drag-and-drop, progress, retry, and status polling
Now the browser side.
HTML
html<!-- frontend/index.html --> <!doctype html> <html> <head> <meta charset="UTF-8" /> <title>Upload Demo</title> <link rel="stylesheet" href="/styles.css" /> </head> <body> <div class="container"> <h1>Upload a file</h1> <div id="dropzone" class="dropzone"> Drag and drop a file here or click to choose <input id="fileInput" type="file" hidden /> </div> <div id="status"></div> <div class="progress-wrapper"> <div id="progressBar" class="progress-bar"></div> </div> <button id="retryBtn" hidden>Retry</button> </div> <script type="module" src="/app.ts"></script> </body> </html>
Frontend logic
The important decision here: use XMLHttpRequest for upload so we get real upload progress events consistently.
ts// frontend/app.ts const API_BASE = 'http://localhost:4000'; const dropzone = document.getElementById('dropzone') as HTMLDivElement; const fileInput = document.getElementById('fileInput') as HTMLInputElement; const statusEl = document.getElementById('status') as HTMLDivElement; const progressBar = document.getElementById('progressBar') as HTMLDivElement; const retryBtn = document.getElementById('retryBtn') as HTMLButtonElement; let currentFile: File | null = null; let currentUploadId: string | null = null; let currentAbortController: AbortController | null = null; function setStatus(message: string) { statusEl.textContent = message; } function setProgress(percent: number) { progressBar.style.width = `${percent}%`; } function getIdempotencyKey(file: File) { return `${file.name}:${file.size}:${file.lastModified}`; } async function createUploadSession(file: File) { const res = await fetch(`${API_BASE}/uploads`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: file.name, contentType: file.type || 'application/octet-stream', size: file.size, idempotencyKey: getIdempotencyKey(file), }), }); if (!res.ok) { throw new Error(`Failed to create upload session: ${res.status}`); } return res.json(); } function uploadFileWithProgress(uploadUrl: string, file: File): Promise<void> { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', uploadUrl, true); xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream'); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { const percent = Math.round((event.loaded / event.total) * 100); setProgress(percent); setStatus(`Uploading... ${percent}%`); } }; 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 = 30000; xhr.send(file); }); } async function finalizeUpload(uploadId: string) { const res = await fetch(`${API_BASE}/uploads/${uploadId}/finalize`, { method: 'POST', }); if (!res.ok) { const body = await res.text(); throw new Error(`Finalize failed: ${res.status} ${body}`); } return res.json(); } async function pollStatus(uploadId: string) { for (let i = 0; i < 20; i++) { const res = await fetch(`${API_BASE}/uploads/${uploadId}`); const data = await res.json(); if (data.status === 'processed') { setStatus('Upload processed successfully'); return; } if (data.status === 'failed') { throw new Error(data.error || 'Processing failed'); } setStatus(`Processing... (${data.status})`); await new Promise((r) => setTimeout(r, 1000)); } throw new Error('Processing timed out'); } async function startUpload(file: File, attempt = 1) { currentFile = file; retryBtn.hidden = true; setProgress(0); setStatus('Preparing upload...'); try { const session = await createUploadSession(file); currentUploadId = session.uploadId; await uploadFileWithProgress(session.uploadUrl, file); setStatus('Finalizing upload...'); await finalizeUpload(session.uploadId); await pollStatus(session.uploadId); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; if ((message.includes('403') || message.includes('Network error')) && attempt < 2) { setStatus('Retrying upload...'); return startUpload(file, attempt + 1); } setStatus(`Upload failed: ${message}`); retryBtn.hidden = false; } } retryBtn.addEventListener('click', () => { if (currentFile) { startUpload(currentFile); } }); dropzone.addEventListener('click', () => fileInput.click()); 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]; if (file) startUpload(file); }); fileInput.addEventListener('change', () => { const file = fileInput.files?.[0]; if (file) startUpload(file); });
This is intentionally small, but it captures the workflow that usually breaks:
- direct browser upload
- real progress events
- retry path
- finalization step
- processing state polling
What this frontend still needs in production
You’d likely add:
- multipart uploads for large files
- checksum validation
- cancellation support
- chunk retry instead of whole-file retry
- auth tokens
- better idempotency scoping per user/session
- backoff with jitter
- upload resume support
But don’t miss the bigger point: even this "simple" uploader already needs browser-aware testing.
Bucket CORS: the part people forget until late Friday
If you’re using MinIO or S3, browser uploads need CORS configured correctly.
A typical S3 CORS config:
json[ { "AllowedHeaders": ["*"], "AllowedMethods": ["PUT", "GET", "HEAD"], "AllowedOrigins": ["http://localhost:3000"], "ExposeHeaders": ["ETag"] } ]
This is exactly the kind of thing unit tests won’t catch. The backend can generate perfect presigned URLs while the browser still fails the actual upload.
Local environment with MinIO
Here’s a minimal docker-compose.yml:
yamlversion: '3.8' services: minio: image: minio/minio command: server /data --console-address ':9001' environment: MINIO_ROOT_USER: minio MINIO_ROOT_PASSWORD: minio123 ports: - '9000:9000' - '9001:9001'
You’ll also want to create a bucket and apply CORS. In a real repo, script this with mc or AWS CLI. That matters for CI/CD because a test environment that needs manual setup is not a real test environment.
The bugs that pass unit tests but fail in browsers
Before writing Playwright tests, let’s be blunt about what pure unit tests miss.
You can unit test:
- that
createUploadSession()calls/uploads - that the backend signs a
PutObjectCommand - that
finalizechecks the DB - that
pollStatusreacts to mocked responses
All useful. None sufficient.
Those tests do not prove:
- the browser can send the file to storage
- CORS headers are accepted by the browser
- progress events update with a real file input
- duplicate clicks don’t create multiple records
- retry recovers from broken network conditions
- finalization works after a real upload
- processing status reaches the user-visible success state
That is the core testing gap. And that gap is where real upload incidents live.
Browser-level verification with Playwright
Playwright is the right level here because it can operate a real browser, attach real files to inputs, and inspect end-user-visible outcomes.
Install Playwright
bashnpm install -D @playwright/test npx playwright install --with-deps
Playwright config
ts// playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', use: { baseURL: 'http://localhost:3000', trace: 'retain-on-failure', }, webServer: [ { command: 'npm run backend', port: 4000, reuseExistingServer: true, timeout: 120000, }, { command: 'npm run frontend', port: 3000, reuseExistingServer: true, timeout: 120000, }, ], });
Happy-path upload test
ts// tests/upload.spec.ts import { test, expect } from '@playwright/test'; import path from 'path'; test('uploads a real file and reaches processed state', async ({ page }) => { await page.goto('/'); const filePath = path.join(process.cwd(), 'tests', 'fixtures', 'sample.csv'); await page.setInputFiles('#fileInput', filePath); await expect(page.locator('#status')).toContainText('Preparing upload'); await expect(page.locator('#status')).toContainText('Uploading', { timeout: 10000 }); await expect(page.locator('#status')).toContainText('Upload processed successfully', { timeout: 15000, }); const width = await page.locator('#progressBar').evaluate((el) => window.getComputedStyle(el).width ); expect(width).toBeTruthy(); });
This alone already proves more than a pile of mocks. It proves the browser file input, backend signing, storage upload, finalize, and processing loop all work together.
Retry test with simulated network interruption
Now the more realistic case.
tsimport { test, expect } from '@playwright/test'; import path from 'path'; test('retries after interrupted upload and still completes', async ({ page, context }) => { let firstUploadAttempt = true; await context.route('http://localhost:9000/**', async (route) => { const request = route.request(); if (request.method() === 'PUT' && firstUploadAttempt) { firstUploadAttempt = false; await route.abort('failed'); return; } await route.continue(); }); await page.goto('/'); const filePath = path.join(process.cwd(), 'tests', 'fixtures', 'sample.csv'); await page.setInputFiles('#fileInput', filePath); await expect(page.locator('#status')).toContainText('Retrying upload', { timeout: 10000 }); await expect(page.locator('#status')).toContainText('Upload processed successfully', { timeout: 20000, }); });
This is where debugging gets practical. If this test fails, you’re looking at the exact user-visible break in the exact layer that matters.
Duplicate submission test
You also want to verify the idempotency behavior from the user’s perspective.
tsimport { test, expect } from '@playwright/test'; import path from 'path'; test('duplicate file selection does not create duplicate flow failures', async ({ page }) => { await page.goto('/'); const filePath = path.join(process.cwd(), 'tests', 'fixtures', 'sample.csv'); await Promise.all([ page.setInputFiles('#fileInput', filePath), page.setInputFiles('#fileInput', filePath), ]); await expect(page.locator('#status')).toContainText('Upload processed successfully', { timeout: 20000, }); });
In a real implementation, I’d also assert on backend state using a test helper endpoint or DB query to confirm there is one upload record, not two.
Processing failure test
This is another thing teams often skip because it feels less glamorous than happy path testing.
tsimport { test, expect } from '@playwright/test'; import path from 'path'; test('surfaces processing failure after successful upload', async ({ page }) => { await page.goto('/'); const filePath = path.join(process.cwd(), 'tests', 'fixtures', 'broken.bad.csv'); await page.setInputFiles('#fileInput', filePath); await expect(page.locator('#status')).toContainText('Upload failed: Processing failed', { timeout: 15000, }); });
This test matters because a lot of teams have excellent instrumentation for upload transport errors and terrible visibility into post-upload failures.
CI/CD that tests the workflow, not just the repo
Now we make this useful in CI/CD.
If your pipeline only runs ESLint, TypeScript, and unit tests, it can still ship a broken uploader. The browser-storage boundary is not covered.
GitHub Actions example
yamlname: ci on: push: pull_request: jobs: test: runs-on: ubuntu-latest services: minio: image: minio/minio ports: - 9000:9000 - 9001:9001 env: MINIO_ROOT_USER: minio MINIO_ROOT_PASSWORD: minio123 options: >- --health-cmd "curl -f http://localhost:9000/minio/health/live || exit 1" --health-interval 5s --health-timeout 5s --health-retries 20 command: server /data --console-address ':9001' env: S3_ENDPOINT: http://localhost:9000 S3_REGION: us-east-1 S3_BUCKET: uploads S3_ACCESS_KEY: minio S3_SECRET_KEY: minio123 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npx playwright install --with-deps - name: Create bucket and set CORS run: | curl -O https://dl.min.io/client/mc/release/linux-amd64/mc chmod +x mc ./mc alias set local http://localhost:9000 minio minio123 ./mc mb -p local/uploads || true cat > cors.json <<'EOF' [ { "AllowedHeaders": ["*"], "AllowedMethods": ["PUT", "GET", "HEAD"], "AllowedOrigins": ["http://localhost:3000"], "ExposeHeaders": ["ETag"] } ] EOF ./mc anonymous set download local/uploads || true - name: Run unit tests run: npm run test:unit - name: Run Playwright workflow tests run: npm run test:e2e
A few notes:
- The object store is part of the test environment, not an external assumption.
- Browser automation runs in CI, not just on a developer laptop.
- The workflow test is allowed to validate the whole user path.
That is what meaningful CI/CD looks like for upload features.
Debugging failures in this setup
When these tests fail, you want the failure to be diagnosable fast. Here’s what helps.
Capture Playwright traces
Playwright traces are incredibly useful for upload debugging because they preserve DOM state, network events, and timing.
Log upload state transitions on the backend
You want structured logs for:
- upload session created
- presigned URL issued
- upload finalized
- object verification result
- processing started
- processing completed or failed
Without that, debugging becomes guesswork.
Distinguish transport failures from processing failures
These are different incidents.
- transport failures: CORS, network, expired URL, storage outage
- processing failures: parser crash, webhook timeout, business validation failure
If your system reports both as "upload failed," your support team and your engineers lose time immediately.
Persist idempotency and workflow state
This demo uses memory, but production needs durable state. Otherwise, a server restart during upload turns debugging into archaeology.
What breaks silently if you skip browser-level verification
This is the uncomfortable part.
If you stop at unit and API tests, your upload feature can ship with all of these problems while still looking healthy internally:
- frontend progress bar never moves because the implementation used the wrong API
- browser rejects the presigned PUT because bucket CORS is wrong
- retries create duplicate processing jobs
- finalize succeeds even though the object is not present yet
- post-upload processing fails and the UI never communicates it
- URL expiration causes flaky behavior only on larger files or slower machines
- CI/CD says green because none of those layers were exercised together
That is why teams overestimate test coverage. They measured code coverage, not workflow coverage.
And with more AI-generated code in the stack, this gets worse. Generated upload code often looks convincing. It uses the right SDKs. It compiles. It may even pass mocked tests. But upload flows are deeply sensitive to browser behavior, storage policy, timing, and retries. Those are integration realities, not syntax problems.
Practical improvements for production
If you’re taking this beyond a demo, here’s what I’d implement next.
1. Multipart uploads for large files
For anything beyond modest file sizes, use multipart upload. Retry individual parts instead of restarting the whole file.
2. Short-lived presigned URLs plus refresh logic
Keep URLs short-lived, but let the client request a fresh URL or fresh part URLs if the upload stalls.
3. Server-side checksum verification
Do not trust upload success blindly. Verify checksums or object metadata where possible.
4. Explicit workflow states
Store states like:
- created
- uploading
- uploaded
- processing
- processed
- failed_transport
- failed_processing
This will save you real debugging time.
5. User-visible retry semantics
Make retries explicit. Silent retries are good up to a point, but after that the user needs honest feedback.
6. Background job observability
If processing happens asynchronously, instrument it like a product-critical path, not a side task.
7. End-to-end tests for real file types
Test at least one representative file for each meaningful class:
- image
- CSV
- large binary file
- malformed file
The app usually breaks on one of those while the others appear fine.
Tooling comparison: what each layer is actually good for
A quick reality-based breakdown.
Unit tests
Good for:
- validation logic
- retry helper behavior
- polling logic
- state reducer logic
Not enough for:
- browser upload behavior
- CORS
- storage integration
- real progress events
API/integration tests
Good for:
- upload session creation
- presigned URL generation
- finalize endpoint semantics
- processing state transitions
Not enough for:
- drag-and-drop/file input behavior
- browser network restrictions
- true user workflow verification
Playwright or browser E2E tests
Good for:
- real file upload workflows
- verifying status UX
- catching CORS, timing, and browser-only regressions
- validating CI/CD confidence for critical flows
Tradeoff:
- slower
- more setup
- worth it for high-friction user actions like uploads, checkout, auth, and onboarding
That last category is where developer productivity actually improves from better testing. Not because you wrote more tests, but because you stopped shipping expensive false positives.
The core lesson
A file upload is not a function call. It is a workflow across the browser, your backend, cloud storage, network conditions, and async processing.
If you only test the backend or only mock the browser calls, you are testing an abstraction, not the feature the user relies on.
The fix is not exotic:
- create uploads through the backend
- upload directly to storage
- make finalize explicit and idempotent
- verify object existence before processing
- surface progress and failures honestly
- run browser-level tests with real files
- include the object store in CI/CD
That combination catches the bugs that matter.
And that’s the broader pattern for modern debugging and testing: stop confusing code-path validation with user-workflow reliability. In a world where more code is generated quickly, that distinction matters even more. The bottleneck is no longer writing plausible code. It’s proving the whole flow actually works under real browser behavior.
If your upload flow is important to your product, treat it like a product-critical workflow and verify it end to end. Anything less is just green checkmarks over assumptions.
