File uploads are one of those features that look done long before they’re actually reliable.
The happy-path demo works in five minutes: pick a file, POST it somewhere, show a success toast. Unit tests pass. CI goes green. Everyone moves on.
Then production starts collecting edge cases:
- the presigned URL expires before the upload starts
- S3 or compatible storage rejects the browser because CORS is wrong
- the UI says “uploaded” even though metadata persistence failed afterward
- progress bars freeze at 95%
- retries duplicate records
- large files pass mocked tests but fail in a real browser under actual network behavior
- drag-and-drop works locally but breaks behind a reverse proxy or stricter CSP
This is the broader problem with modern testing: we’re often proving code paths, not user workflows. And with AI generating more application code than ever, that gap matters more. A file upload feature is a perfect example of where traditional testing and CI/CD can give false confidence. You can have perfect unit coverage and still ship something users can’t actually use.
In this article, we’ll build a real upload flow end to end and verify it at the browser level:
- a drag-and-drop frontend in React + TypeScript
- a backend endpoint that returns presigned upload URLs
- direct-to-storage uploads
- real progress updates
- retry handling for expired URLs and transient failures
- post-upload metadata persistence
- storage-side validation
- Playwright tests that upload actual files and simulate failure modes
- CI steps that run the whole thing in a real browser instead of asserting mocked handlers
The stack here uses Node, Express, React, TypeScript, and Amazon S3 semantics. The same design applies if you use Cloudflare R2, MinIO, GCS with signed URLs, or another object store.
What we’re building and why this design holds up better
The architecture is simple on purpose.
- The browser asks the backend for an upload session.
- The backend validates the request and creates a presigned PUT URL for object storage.
- The browser uploads the file directly to storage using that URL.
- The browser calls the backend again to finalize the upload and persist metadata.
- The backend verifies the object exists and stores the record.
That split matters.
If you upload through your app server, you turn your API into a bandwidth bottleneck and make retries harder. If you upload directly to storage without a finalize step, your database and storage drift apart. If you finalize without verifying storage, you can end up with records that point at nothing.
The flow we want is:
- fast for users
- cheap for infrastructure
- explicit about state transitions
- testable in a real browser
- resilient to network interruptions and stale presigned URLs
We’ll build around a few non-negotiables:
- validate early on the backend
- keep upload state explicit in the client
- separate
uploading to storagefrompersisting metadata - make retries idempotent where possible
- verify the object exists before recording success
- test actual browser uploads, not only mocked handlers
Project structure
We’ll use a simple monorepo-style layout:
txtfile-upload-demo/ server/ src/ index.ts storage.ts db.ts types.ts web/ src/ App.tsx upload.ts api.ts types.ts tests/ upload.spec.ts package.json playwright.config.ts docker-compose.yml
For the code below, assume:
- storage is S3-compatible
- metadata persistence uses a lightweight in-memory DB for demo purposes
- in production you’d swap that for Postgres or equivalent
Backend: create upload sessions and finalize safely
Let’s start with the server.
Shared types
ts// server/src/types.ts export type CreateUploadRequest = { fileName: string; fileType: string; fileSize: number; }; export type CreateUploadResponse = { uploadId: string; objectKey: string; uploadUrl: string; expiresAt: string; maxFileSize: number; }; export type FinalizeUploadRequest = { uploadId: string; objectKey: string; fileName: string; fileType: string; fileSize: number; }; export type FileRecord = { id: string; uploadId: string; objectKey: string; fileName: string; fileType: string; fileSize: number; etag?: string; createdAt: string; };
Minimal in-memory persistence
ts// server/src/db.ts import { FileRecord } from './types'; const files = new Map<string, FileRecord>(); export function insertFile(record: FileRecord) { files.set(record.id, record); return record; } export function findByUploadId(uploadId: string) { return Array.from(files.values()).find((f) => f.uploadId === uploadId); } export function listFiles() { return Array.from(files.values()); }
In production, the uploadId should have a unique constraint. That’s how you prevent duplicate finalize calls from creating duplicate metadata rows.
S3 utilities
ts// server/src/storage.ts import { S3Client, HeadObjectCommand, PutObjectCommand, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; const region = process.env.AWS_REGION || 'us-east-1'; const bucket = process.env.S3_BUCKET || 'uploads-demo'; const endpoint = process.env.S3_ENDPOINT; export const s3 = new S3Client({ region, endpoint, forcePathStyle: !!endpoint, credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin', }, }); export const MAX_FILE_SIZE = 10 * 1024 * 1024; export async function createPresignedUploadUrl(params: { objectKey: string; contentType: string; }) { const command = new PutObjectCommand({ Bucket: bucket, Key: params.objectKey, ContentType: params.contentType, }); const expiresIn = 60; const uploadUrl = await getSignedUrl(s3, command, { expiresIn }); return { uploadUrl, expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(), }; } export async function headObject(objectKey: string) { const result = await s3.send( new HeadObjectCommand({ Bucket: bucket, Key: objectKey, }) ); return result; } export function getBucketName() { return bucket; }
Express app
ts// server/src/index.ts import express from 'express'; import cors from 'cors'; import { randomUUID } from 'crypto'; import { createPresignedUploadUrl, headObject, MAX_FILE_SIZE } from './storage'; import { insertFile, findByUploadId, listFiles } from './db'; import { CreateUploadRequest, FinalizeUploadRequest } from './types'; const app = express(); app.use(cors({ origin: 'http://localhost:5173' })); app.use(express.json()); function sanitizeFileName(name: string) { return name.replace(/[^a-zA-Z0-9._-]/g, '_'); } app.post('/api/uploads', async (req, res) => { const body = req.body as CreateUploadRequest; if (!body.fileName || !body.fileType || !body.fileSize) { return res.status(400).json({ error: 'Missing required fields' }); } if (body.fileSize > MAX_FILE_SIZE) { return res.status(400).json({ error: `File too large. Max size is ${MAX_FILE_SIZE} bytes`, }); } const uploadId = randomUUID(); const objectKey = `uploads/${uploadId}/${sanitizeFileName(body.fileName)}`; try { const { uploadUrl, expiresAt } = await createPresignedUploadUrl({ objectKey, contentType: body.fileType, }); return res.json({ uploadId, objectKey, uploadUrl, expiresAt, maxFileSize: MAX_FILE_SIZE, }); } catch (error) { console.error('Failed to create upload URL', error); return res.status(500).json({ error: 'Failed to create upload URL' }); } }); app.post('/api/uploads/finalize', async (req, res) => { const body = req.body as FinalizeUploadRequest; if (!body.uploadId || !body.objectKey || !body.fileName) { return res.status(400).json({ error: 'Missing required fields' }); } const existing = findByUploadId(body.uploadId); if (existing) { return res.json(existing); } try { const head = await headObject(body.objectKey); const actualSize = Number(head.ContentLength || 0); const etag = head.ETag?.replaceAll('"', ''); if (actualSize !== body.fileSize) { return res.status(409).json({ error: 'Uploaded object size does not match expected file size', }); } const record = insertFile({ id: randomUUID(), uploadId: body.uploadId, objectKey: body.objectKey, fileName: body.fileName, fileType: body.fileType, fileSize: body.fileSize, etag, createdAt: new Date().toISOString(), }); return res.json(record); } catch (error) { console.error('Finalize failed', error); return res.status(400).json({ error: 'Uploaded object not found or not accessible', }); } }); app.get('/api/files', (_req, res) => { return res.json(listFiles()); }); const port = Number(process.env.PORT || 3001); app.listen(port, () => { console.log(`server listening on http://localhost:${port}`); });
This finalize step is where a lot of teams cut corners.
They assume that if the browser got a 200 from the upload request, the feature is done. But users don’t care whether one request succeeded. They care whether the uploaded file is actually available in the system, attached to the right record, and visible in the UI.
A direct-to-storage upload without finalize verification is just half a workflow.
Frontend: drag-and-drop, progress, retries, and explicit state
The frontend needs to represent the upload as a small state machine, not a boolean.
A boolean like isUploading collapses too many distinct states:
- selecting
- requesting presigned URL
- uploading bytes
- retrying
- finalizing metadata
- success
- failed
When teams skip that distinction, stale UI state creeps in fast.
Frontend types
ts// web/src/types.ts export type UploadPhase = | 'idle' | 'requesting_url' | 'uploading' | 'retrying' | 'finalizing' | 'success' | 'error'; export type UploadState = { phase: UploadPhase; progress: number; error?: string; uploadId?: string; objectKey?: string; }; export type CreateUploadResponse = { uploadId: string; objectKey: string; uploadUrl: string; expiresAt: string; maxFileSize: number; };
API helpers
ts// web/src/api.ts export async function createUploadSession(file: File) { const res = await fetch('http://localhost:3001/api/uploads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: file.name, fileType: file.type || 'application/octet-stream', fileSize: file.size, }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || 'Failed to create upload session'); } return res.json(); } export async function finalizeUpload(params: { uploadId: string; objectKey: string; file: File; }) { const res = await fetch('http://localhost:3001/api/uploads/finalize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId: params.uploadId, objectKey: params.objectKey, fileName: params.file.name, fileType: params.file.type || 'application/octet-stream', fileSize: params.file.size, }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || 'Failed to finalize upload'); } return res.json(); }
Upload transport with progress reporting
You still need XMLHttpRequest if you want broad, simple upload progress in browsers. fetch doesn’t provide upload progress in a practical cross-browser way for this use case.
ts// web/src/upload.ts export function uploadWithProgress(params: { url: string; file: File; onProgress: (percent: number) => void; }) { const { url, file, onProgress } = params; return new Promise<void>((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', url, 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); onProgress(percent); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { onProgress(100); resolve(); } else { reject(new Error(`Upload failed with status ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error('Network error during upload')); xhr.onabort = () => reject(new Error('Upload aborted')); xhr.send(file); }); }
Retry policy
We’ll handle two common retry classes:
- upload transport failed due to network or transient issue
- presigned URL expired or became invalid, so we need a new session
ts// web/src/upload.ts import { createUploadSession, finalizeUpload } from './api'; export async function runUpload(params: { file: File; onState: (patch: Record<string, unknown>) => void; }) { const { file, onState } = params; let lastError: unknown; for (let attempt = 1; attempt <= 2; attempt++) { try { onState({ phase: attempt === 1 ? 'requesting_url' : 'retrying', error: undefined, progress: 0 }); const session = await createUploadSession(file); onState({ uploadId: session.uploadId, objectKey: session.objectKey, phase: 'uploading', }); await uploadWithProgress({ url: session.uploadUrl, file, onProgress: (progress) => onState({ progress, phase: 'uploading' }), }); onState({ phase: 'finalizing' }); const record = await finalizeUpload({ uploadId: session.uploadId, objectKey: session.objectKey, file, }); onState({ phase: 'success', progress: 100 }); return record; } catch (error: any) { lastError = error; const message = String(error?.message || 'Unknown upload error'); const retryable = message.includes('Network error') || message.includes('status 403') || message.includes('status 408') || message.includes('status 5'); if (!retryable || attempt === 2) { break; } } } throw lastError; }
This is intentionally simple, but it gets one critical behavior right: if the URL is bad, we don’t retry the same URL forever. We request a fresh presigned URL and try again.
That’s a real production failure mode. In mocked tests, URLs don’t expire unless you fake it. In production, users drag a file in, get distracted, switch tabs, or lose connectivity. By the time the upload starts or resumes, the URL may be invalid.
React UI
tsx// web/src/App.tsx import React, { useRef, useState } from 'react'; import { runUpload } from './upload'; import { UploadState } from './types'; const initialState: UploadState = { phase: 'idle', progress: 0, }; export default function App() { const [state, setState] = useState<UploadState>(initialState); const [uploadedFiles, setUploadedFiles] = useState<any[]>([]); const inputRef = useRef<HTMLInputElement | null>(null); async function handleFile(file: File) { setState(initialState); try { const record = await runUpload({ file, onState: (patch) => { setState((prev) => ({ ...prev, ...patch } as UploadState)); }, }); setUploadedFiles((prev) => [record, ...prev]); } catch (error: any) { setState((prev) => ({ ...prev, phase: 'error', error: error?.message || 'Upload failed', })); } } function onDrop(e: React.DragEvent<HTMLDivElement>) { e.preventDefault(); const file = e.dataTransfer.files?.[0]; if (file) void handleFile(file); } return ( <div style={{ maxWidth: 760, margin: '40px auto', fontFamily: 'sans-serif' }}> <h1>Upload demo</h1> <div onDragOver={(e) => e.preventDefault()} onDrop={onDrop} onClick={() => inputRef.current?.click()} data-testid="dropzone" style={{ border: '2px dashed #999', borderRadius: 12, padding: 32, cursor: 'pointer', marginBottom: 20, }} > Drag and drop a file here, or click to select. </div> <input ref={inputRef} type="file" hidden data-testid="file-input" onChange={(e) => { const file = e.target.files?.[0]; if (file) void handleFile(file); }} /> <div data-testid="status">Phase: {state.phase}</div> <div data-testid="progress">Progress: {state.progress}%</div> {state.error && <div data-testid="error">Error: {state.error}</div>} <hr /> <h2>Uploaded files</h2> <ul data-testid="uploaded-list"> {uploadedFiles.map((f) => ( <li key={f.id}> {f.fileName} ({f.fileSize} bytes) </li> ))} </ul> </div> ); }
This UI is ugly by design. Don’t optimize cosmetics before you’ve made the workflow trustworthy.
Storage CORS: where “works locally” often dies
Presigned uploads from the browser live or die on CORS.
Your app server can have perfect CORS config and the upload still fails because object storage rejects the browser’s preflight or PUT request.
For S3, you need bucket CORS that allows your frontend origin, the PUT method, and headers like Content-Type.
Example:
json[ { "AllowedHeaders": ["*"], "AllowedMethods": ["PUT", "GET", "HEAD"], "AllowedOrigins": ["http://localhost:5173"], "ExposeHeaders": ["ETag"] } ]
If this is wrong, the browser upload fails before your app logic gets much of a say. That’s exactly why unit tests and mocked integration tests miss it. They don’t exercise browser CORS behavior against the real storage endpoint.
What silently breaks if you skip storage verification
Let’s be blunt: a lot of upload implementations lie.
Not intentionally, but structurally.
Common broken states:
- the UI shows success after the PUT finishes, but finalize failed
- the DB record exists, but the object is missing because the upload never completed properly
- the object exists, but the size is wrong because a proxy cut the connection mid-stream
- retries created duplicate rows because finalize wasn’t idempotent
- the user refreshed after upload and the file vanished because the list was only local state
The HEAD check in finalize is not expensive compared to the cost of storing bad metadata or forcing users to re-upload with no explanation.
You want success to mean: the bytes exist in storage and the application knows about them.
Playwright: verify the workflow in a real browser
Now for the part most teams skip.
We’re not going to “test uploads” by mocking the upload function and asserting the progress callback fired. That proves your callback code runs, not that a user can upload a file.
We want browser-level verification that exercises:
- file input handling
- actual network requests
- CORS behavior where possible in the environment
- progress UI transitions
- retry behavior
- finalize persistence
Playwright config
ts// playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', use: { baseURL: 'http://localhost:5173', headless: true, }, webServer: [ { command: 'npm run dev:web', url: 'http://localhost:5173', reuseExistingServer: !process.env.CI, }, { command: 'npm run dev:server', url: 'http://localhost:3001/api/files', reuseExistingServer: !process.env.CI, }, ], });
Happy-path upload test with a real file
ts// tests/upload.spec.ts import { test, expect } from '@playwright/test'; import path from 'path'; test('uploads a real file and persists metadata', async ({ page, request }) => { await page.goto('/'); const filePath = path.join(__dirname, 'fixtures', 'sample.txt'); await page.getByTestId('file-input').setInputFiles(filePath); await expect(page.getByTestId('status')).toContainText('success', { timeout: 15000, }); await expect(page.getByTestId('progress')).toContainText('100%'); await expect(page.getByTestId('uploaded-list')).toContainText('sample.txt'); const apiRes = await request.get('http://localhost:3001/api/files'); const files = await apiRes.json(); expect(files.some((f: any) => f.fileName === 'sample.txt')).toBeTruthy(); });
That one test already gives you more confidence than a pile of mocked upload tests, because it proves the browser can select a file, issue real requests, complete the flow, and observe persisted state.
Simulate a transient upload failure and verify retry
We can intercept the presigned upload request and fail the first attempt.
tsimport { test, expect } from '@playwright/test'; import path from 'path'; test('retries when the direct upload fails once', async ({ page }) => { let uploadAttempt = 0; await page.route(/uploads\/.+/, async (route) => { const request = route.request(); if (request.method() === 'PUT') { uploadAttempt += 1; if (uploadAttempt === 1) { await route.fulfill({ status: 500, body: 'simulated transient failure', }); return; } } await route.continue(); }); await page.goto('/'); const filePath = path.join(__dirname, 'fixtures', 'sample.txt'); await page.getByTestId('file-input').setInputFiles(filePath); await expect(page.getByTestId('status')).toContainText('success', { timeout: 15000, }); });
A test like this catches real workflow bugs fast:
- retry didn’t request a fresh URL
- state stayed stuck in
retrying - progress never reset correctly
- duplicate finalize calls created duplicate records
Simulate expired URL behavior
Another common production case: storage returns 403 because the signed URL is no longer valid.
tstest('requests a new upload session when the signed URL is expired', async ({ page }) => { let putAttempt = 0; await page.route(/uploads\/.+/, async (route) => { if (route.request().method() === 'PUT') { putAttempt += 1; if (putAttempt === 1) { await route.fulfill({ status: 403, body: 'expired signature', }); return; } } await route.continue(); }); await page.goto('/'); await page.getByTestId('file-input').setInputFiles( path.join(__dirname, 'fixtures', 'sample.txt') ); await expect(page.getByTestId('status')).toContainText('success', { timeout: 15000, }); });
You can make this stricter by recording how many times /api/uploads is called and asserting that a second session was requested after the 403.
Simulate finalize failure
Uploads don’t end at byte transfer. If finalize fails, the UI must not claim success.
tstest('does not show success if metadata finalize fails', async ({ page }) => { await page.route('http://localhost:3001/api/uploads/finalize', async (route) => { await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'db unavailable' }), }); }); await page.goto('/'); await page.getByTestId('file-input').setInputFiles( path.join(__dirname, 'fixtures', 'sample.txt') ); await expect(page.getByTestId('status')).toContainText('error', { timeout: 15000, }); await expect(page.getByTestId('error')).toContainText('db unavailable'); });
This test matters because teams often stop assertions after the upload request succeeds. That’s how stale UI state gets shipped.
Oversized file validation
Backends should reject invalid uploads before issuing presigned URLs.
tstest('rejects oversized files with a clear error', async ({ page }) => { await page.goto('/'); const largeBuffer = Buffer.alloc(11 * 1024 * 1024, 'a'); await page.getByTestId('file-input').setInputFiles({ name: 'too-large.bin', mimeType: 'application/octet-stream', buffer: largeBuffer, }); await expect(page.getByTestId('status')).toContainText('error', { timeout: 15000, }); await expect(page.getByTestId('error')).toContainText('File too large'); });
That catches drift between frontend assumptions and backend enforcement.
CI/CD: make the browser workflow part of your reliability gate
If your CI/CD pipeline only runs lint, unit tests, and mocked integration tests, it’s not validating the feature your users touch.
For upload-heavy apps, browser-level tests should be part of the merge gate.
Example GitHub Actions workflow
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: minioadmin MINIO_ROOT_PASSWORD: minioadmin 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" steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - name: Install Playwright browsers run: npx playwright install --with-deps chromium - name: Create bucket and set CORS run: | docker run --network host --rm minio/mc \ alias set local http://127.0.0.1:9000 minioadmin minioadmin docker run --network host --rm -v ${{ github.workspace }}/.ci:/cfg minio/mc \ mb --ignore-existing local/uploads-demo docker run --network host --rm -v ${{ github.workspace }}/.ci:/cfg minio/mc \ anonymous set download local/uploads-demo || true - name: Run Playwright upload tests env: AWS_REGION: us-east-1 AWS_ACCESS_KEY_ID: minioadmin AWS_SECRET_ACCESS_KEY: minioadmin S3_BUCKET: uploads-demo S3_ENDPOINT: http://127.0.0.1:9000 CI: true run: npx playwright test
You’d likely add a proper bucket CORS setup command or startup script depending on your MinIO or S3-compatible environment.
The point isn’t the exact YAML. The point is this: your merge gate should verify the real browser flow against running services.
That’s what improves developer productivity in the long run. Not more green checks, but fewer misleading green checks.
Debugging the failures you’ll actually see
When this kind of workflow breaks, here’s the order I debug it in.
1. Did the backend issue a valid presigned URL?
Check:
- request payload from browser to
/api/uploads - content type used during signing
- expiration window
- object key format
A mismatch between signed Content-Type and the actual request header can break uploads in annoying ways.
2. Did the browser send the PUT request at all?
Open the browser network panel or inspect Playwright traces.
If not, suspect:
- file input or drag-drop event issue
- JavaScript error before transport starts
- CORS preflight failure
3. Did storage reject the request?
Common causes:
- CORS misconfiguration
- expired signature
- wrong bucket policy
- reverse proxy rewriting headers
- clock skew in environments issuing signatures
4. Did finalize verify the right object?
If upload succeeded but finalize failed:
- confirm
objectKeymatches the uploaded object - verify
HEADpermissions - compare reported size with actual size
- check whether storage is eventually consistent in your environment
5. Did the UI transition to the right terminal state?
This is where stale state bugs show up.
A reliable UI should:
- show
errorif finalize fails - show
successonly after finalize succeeds - clear old errors on new attempts
- avoid leaving the previous file listed if the new upload failed
That’s not cosmetic. It’s workflow correctness.
Practical improvements for production
The demo uses single-request PUT uploads. That’s enough for many cases, but for larger files you’ll want multipart upload.
The same principles still apply:
- request upload intent from backend
- upload parts directly to storage
- track progress across parts
- retry individual parts
- complete multipart upload on the backend
- verify the final object before metadata persistence
- test it in a real browser
Other production improvements worth adding:
Add content hashing
If integrity really matters, calculate a checksum and verify it server-side or during finalize when your storage supports it.
Persist upload sessions
If users refresh mid-upload, an in-memory app forgets everything. Persist upload session records so you can recover state or clean up abandoned objects.
Add cleanup for orphaned uploads
Some uploads will succeed to storage but never finalize because the user closed the tab. A scheduled cleanup job should remove old orphaned objects or mark incomplete sessions.
Improve retry backoff
Use exponential backoff with jitter for transient failures instead of immediate retry loops.
Instrument the workflow
Emit metrics for:
- upload session creation failures
- PUT failures by status code
- finalize failures
- average upload duration
- retry frequency
- orphaned upload count
That turns debugging from guesswork into operations.
Why traditional testing misses this class of bug
This is the bigger lesson.
A lot of software teams still treat testing as a stack of isolated proofs:
- unit tests prove functions
- integration tests prove internal APIs
- CI/CD proves the branch is safe
But user-facing reliability usually breaks in the seams:
- browser + storage CORS
- signed URL lifetime + user delay
- progress UI + network interruption
- upload success + metadata failure
- retries + duplicate persistence
Those seams are exactly where browser-level workflow testing pays off.
This is especially relevant now because AI can generate the scaffolding for upload flows, API handlers, and test files extremely quickly. That boosts output, but it also increases the chance that teams ship workflows that look complete and compile fine while missing the ugly operational edge cases.
That’s why debugging and testing strategy need to evolve. More generated code means you need stronger verification of real behavior, not more trust in syntactic completeness.
Recommended testing split
I’m not arguing against unit tests. You still want them.
A sensible split looks like this:
- unit tests for filename sanitization, validation rules, retry classifiers
- API tests for
/api/uploadsand/api/uploads/finalize - browser workflow tests for real file selection, upload, retry, and finalize behavior
- a small number of failure injection tests in Playwright
What you should avoid is over-investing in mocked tests for the exact behavior users care about most.
If the business outcome is “customers can upload files reliably,” then your test strategy needs at least one layer that proves customers can upload files reliably.
Wrap-up
A production-worthy file upload flow is not “pick file, send request, show toast.”
It’s a workflow with multiple failure boundaries:
- request a valid upload target
- send bytes directly to storage
- surface real progress
- recover from interruptions
- verify the object exists
- persist metadata exactly once
- present honest UI state
- prove it all works in a real browser in CI/CD
That last step is the one that changes the reliability story.
Without browser-level verification, upload features routinely pass tests while failing in actual use. The result is wasted debugging time, false confidence from CI, and lower developer productivity because engineers end up firefighting state mismatches and environment-specific bugs after release.
With the approach here, your tests stop asking, “did our upload code path run?” and start asking the only question that matters:
Did a user really upload a file, under realistic conditions, and did the system end in the correct state?
That’s the standard worth shipping against.
