Most file upload features look done long before they’re actually reliable.
The UI accepts a file. The backend returns a presigned URL. The frontend shows a progress bar and a success toast. CI passes because the component test mocked fetch, the API test asserted a 200, and maybe one end-to-end test checked that the button became disabled and then re-enabled.
And then production happens.
Uploads fail because the browser sends image/jpeg while the backend signed for application/octet-stream. CORS blocks the PUT from the browser but only in your deployed environment. S3-compatible storage accepts the object, but your app reads it back too early and gets a transient 404. The signature expires on a slow CI runner. Post-upload processing never runs, so the user sees “uploaded” but the file never appears where it matters.
This is exactly the kind of gap that makes teams overconfident in CI/CD while still shipping broken workflows. Unit tests and mocked integration tests are useful, but they do not prove that a user can drop a real file into the UI, upload it through your actual browser code, store it in object storage, and retrieve it successfully afterward.
That’s what we’re going to build here.
We’ll implement a minimal but real upload flow end to end:
- a React drag-and-drop file upload UI
- a backend that issues presigned upload URLs
- direct browser upload to S3-compatible storage
- progress and error handling
- a post-upload finalize step
- a Playwright test that uploads a real file and verifies it is actually retrievable
- a CI setup that runs the whole thing without cheating
The stack is intentionally simple:
- Frontend: React + Vite + TypeScript
- Backend: Node.js + Express + TypeScript
- Storage: local S3-compatible service via MinIO
- Tests: Playwright
- CI: GitHub Actions
The important part is not the exact framework choice. The important part is the workflow: verify the user path, not just the code path.
What we’re building
The flow looks like this:
- User drags a file into the browser UI.
- Frontend asks the backend for a presigned upload URL.
- Backend generates a storage key and returns a short-lived presigned PUT URL.
- Frontend uploads the real file directly to object storage using that URL.
- Frontend calls a finalize endpoint so the backend can record metadata.
- UI shows the uploaded asset.
- Playwright proves the file is not only “uploaded” according to the UI, but actually retrievable from storage through the application.
That final verification is where most teams stop short. They assert a toast. We’re going further.
Project structure
We’ll use a small monorepo-style layout:
txtfile-upload-demo/ docker-compose.yml package.json apps/ web/ src/ App.tsx main.tsx styles.css index.html package.json tsconfig.json vite.config.ts api/ src/ index.ts storage.ts types.ts package.json tsconfig.json tests/ fixtures/ sample.png upload.spec.ts .github/ workflows/ ci.yml
Storage setup with MinIO
For local development and CI, MinIO is perfect. It gives you an S3-compatible API without depending on external AWS infrastructure.
Create docker-compose.yml:
yamlversion: "3.8" services: minio: image: minio/minio:latest command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin ports: - "9000:9000" - "9001:9001"
We’ll use bucket uploads.
Root package.json
At the repo root:
json{ "name": "file-upload-demo", "private": true, "workspaces": ["apps/*"], "scripts": { "dev:web": "npm --workspace apps/web run dev", "dev:api": "npm --workspace apps/api run dev", "build": "npm --workspace apps/api run build && npm --workspace apps/web run build", "test:e2e": "playwright test" }, "devDependencies": { "@playwright/test": "^1.48.2" } }
Backend: presigned URLs and finalize flow
The backend has three jobs:
- ensure the bucket exists
- issue a presigned PUT URL for the exact content type
- finalize the upload and expose metadata for retrieval
API package.json
Create apps/api/package.json:
json{ "name": "api", "type": "module", "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc -p tsconfig.json", "start": "node dist/index.js" }, "dependencies": { "@aws-sdk/client-s3": "^3.654.0", "@aws-sdk/s3-request-presigner": "^3.654.0", "cors": "^2.8.5", "express": "^4.21.0", "mime-types": "^2.1.35", "zod": "^3.23.8" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "tsx": "^4.19.1", "typescript": "^5.6.2" } }
API tsconfig
apps/api/tsconfig.json:
json{ "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "Bundler", "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src"] }
Storage helper
apps/api/src/storage.ts:
tsimport { CreateBucketCommand, HeadBucketCommand, PutObjectCommand, S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; const endpoint = process.env.S3_ENDPOINT ?? "http://127.0.0.1:9000"; const region = process.env.S3_REGION ?? "us-east-1"; const accessKeyId = process.env.S3_ACCESS_KEY_ID ?? "minioadmin"; const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY ?? "minioadmin"; export const bucketName = process.env.S3_BUCKET ?? "uploads"; export const s3 = new S3Client({ region, endpoint, forcePathStyle: true, credentials: { accessKeyId, secretAccessKey } }); export async function ensureBucketExists() { try { await s3.send(new HeadBucketCommand({ Bucket: bucketName })); } catch { await s3.send(new CreateBucketCommand({ Bucket: bucketName })); } } export async function createPresignedUploadUrl(params: { key: string; contentType: string; expiresInSeconds?: number; }) { const command = new PutObjectCommand({ Bucket: bucketName, Key: params.key, ContentType: params.contentType }); return getSignedUrl(s3, command, { expiresIn: params.expiresInSeconds ?? 60 }); } export function getPublicObjectUrl(key: string) { const base = endpoint.replace("127.0.0.1", "localhost"); return `${base}/${bucketName}/${key}`; } export async function createPresignedDownloadUrl(key: string) { const command = new GetObjectCommand({ Bucket: bucketName, Key: key }); return getSignedUrl(s3, command, { expiresIn: 300 }); }
A quick note here: we sign the ContentType into the PUT request. This is one of the easiest ways to catch a real class of production bugs. If the frontend sends a different content type than the backend signed for, the upload fails. Good. That’s better than silently accepting inconsistent metadata and debugging broken previews later.
API types
apps/api/src/types.ts:
tsexport type UploadRecord = { id: string; fileName: string; contentType: string; size: number; storageKey: string; status: "pending" | "uploaded"; };
API server
apps/api/src/index.ts:
tsimport express from "express"; import cors from "cors"; import { z } from "zod"; import crypto from "node:crypto"; import { bucketName, createPresignedDownloadUrl, createPresignedUploadUrl, ensureBucketExists, s3 } from "./storage.js"; import { HeadObjectCommand } from "@aws-sdk/client-s3"; import type { UploadRecord } from "./types.js"; const app = express(); const port = Number(process.env.PORT ?? 4000); app.use(cors({ origin: "http://127.0.0.1:5173" })); app.use(express.json()); const uploads = new Map<string, UploadRecord>(); const createUploadSchema = z.object({ fileName: z.string().min(1), contentType: z.string().min(1), size: z.number().positive() }); const finalizeUploadSchema = z.object({ uploadId: z.string().min(1) }); app.get("/health", (_req, res) => { res.json({ ok: true, bucketName }); }); app.post("/uploads", async (req, res) => { const parsed = createUploadSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "Invalid payload" }); } const { fileName, contentType, size } = parsed.data; const uploadId = crypto.randomUUID(); const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, "_"); const key = `${uploadId}/${safeName}`; const uploadUrl = await createPresignedUploadUrl({ key, contentType, expiresInSeconds: 60 }); uploads.set(uploadId, { id: uploadId, fileName, contentType, size, storageKey: key, status: "pending" }); res.json({ uploadId, key, uploadUrl }); }); app.post("/uploads/finalize", async (req, res) => { const parsed = finalizeUploadSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "Invalid payload" }); } const record = uploads.get(parsed.data.uploadId); if (!record) { return res.status(404).json({ error: "Upload not found" }); } try { const head = await s3.send( new HeadObjectCommand({ Bucket: bucketName, Key: record.storageKey }) ); if ((head.ContentLength ?? 0) !== record.size) { return res.status(409).json({ error: "Uploaded object size mismatch" }); } record.status = "uploaded"; uploads.set(record.id, record); const downloadUrl = await createPresignedDownloadUrl(record.storageKey); return res.json({ ok: true, upload: { id: record.id, fileName: record.fileName, contentType: record.contentType, size: record.size, status: record.status, downloadUrl } }); } catch (error) { return res.status(409).json({ error: "Object not available yet" }); } }); app.get("/uploads/:id", async (req, res) => { const record = uploads.get(req.params.id); if (!record) { return res.status(404).json({ error: "Not found" }); } const downloadUrl = record.status === "uploaded" ? await createPresignedDownloadUrl(record.storageKey) : null; res.json({ upload: { id: record.id, fileName: record.fileName, contentType: record.contentType, size: record.size, status: record.status, downloadUrl } }); }); async function start() { await ensureBucketExists(); app.listen(port, () => { console.log(`API listening on http://127.0.0.1:${port}`); }); } start().catch((err) => { console.error(err); process.exit(1); });
There are two design decisions here worth calling out.
First, we require a finalize step. That’s not accidental overhead. It gives your application a place to verify the object exists, check metadata, kick off processing, and transition state from “pending” to “uploaded.” If you skip this and immediately declare success after the PUT returns 200, you create a blind spot between storage and product behavior.
Second, we use HeadObject during finalize. That catches a real class of bugs: upload succeeded from the browser’s point of view, but the object isn’t actually available or doesn’t match what we expected.
Frontend: drag-and-drop UI with real upload progress
Now the web app.
Web package.json
apps/web/package.json:
json{ "name": "web", "private": true, "type": "module", "scripts": { "dev": "vite --host 127.0.0.1 --port 5173", "build": "tsc -b && vite build", "preview": "vite preview" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "@types/react": "^18.3.9", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "typescript": "^5.6.2", "vite": "^5.4.8" } }
Web tsconfig
apps/web/tsconfig.json:
json{ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2020"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" }, "include": ["src"] }
Vite config
apps/web/vite.config.ts:
tsimport { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()] });
Main app
apps/web/src/main.tsx:
tsximport React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import "./styles.css"; ReactDOM.createRoot(document.getElementById("root")!).render( <React.StrictMode> <App /> </React.StrictMode> );
Upload UI
apps/web/src/App.tsx:
tsximport { useRef, useState } from "react"; type UploadedFile = { id: string; fileName: string; contentType: string; size: number; status: string; downloadUrl: string | null; }; const API_BASE = "http://127.0.0.1:4000"; export default function App() { const [dragActive, setDragActive] = useState(false); const [progress, setProgress] = useState(0); const [status, setStatus] = useState<string>("Idle"); const [error, setError] = useState<string | null>(null); const [uploaded, setUploaded] = useState<UploadedFile | null>(null); const inputRef = useRef<HTMLInputElement | null>(null); async function handleFile(file: File) { setError(null); setUploaded(null); setProgress(0); setStatus("Requesting upload URL..."); const createRes = 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 }) }); if (!createRes.ok) { throw new Error("Failed to create upload"); } const { uploadId, uploadUrl } = await createRes.json(); setStatus("Uploading file..."); await uploadWithProgress(uploadUrl, file, setProgress); setStatus("Finalizing upload..."); const finalizeRes = await fetch(`${API_BASE}/uploads/finalize`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uploadId }) }); if (!finalizeRes.ok) { const data = await finalizeRes.json().catch(() => ({})); throw new Error(data.error || "Failed to finalize upload"); } const data = await finalizeRes.json(); setUploaded(data.upload); setStatus("Upload complete"); setProgress(100); } function onSelectFile(file?: File | null) { if (!file) return; handleFile(file).catch((err) => { setError(err.message || "Unexpected upload error"); setStatus("Upload failed"); }); } return ( <div className="page"> <div className="card"> <h1>File upload demo</h1> <div data-testid="dropzone" className={`dropzone ${dragActive ? "active" : ""}`} onDragOver={(e) => { e.preventDefault(); setDragActive(true); }} onDragLeave={() => setDragActive(false)} onDrop={(e) => { e.preventDefault(); setDragActive(false); onSelectFile(e.dataTransfer.files?.[0]); }} onClick={() => inputRef.current?.click()} > <p>Drag and drop a file here, or click to select</p> <input ref={inputRef} data-testid="file-input" type="file" hidden onChange={(e) => onSelectFile(e.target.files?.[0])} /> </div> <div className="status-row"> <strong>Status:</strong> <span data-testid="status">{status}</span> </div> <div className="progress-bar"> <div className="progress-bar-fill" style={{ width: `${progress}%` }} data-testid="progress" /> </div> {error && ( <div className="error" data-testid="error"> {error} </div> )} {uploaded && ( <div className="uploaded" data-testid="uploaded-result"> <div><strong>Name:</strong> {uploaded.fileName}</div> <div><strong>Type:</strong> {uploaded.contentType}</div> <div><strong>Size:</strong> {uploaded.size}</div> <div><strong>Status:</strong> {uploaded.status}</div> {uploaded.downloadUrl && ( <img data-testid="uploaded-image" src={uploaded.downloadUrl} alt="Uploaded preview" style={{ maxWidth: 240, marginTop: 12 }} /> )} </div> )} </div> </div> ); } function uploadWithProgress( url: string, file: File, onProgress: (value: number) => void ) { 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) return; const percent = Math.round((event.loaded / event.total) * 100); onProgress(percent); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { reject(new Error(`Upload failed with status ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error("Network error during upload")); xhr.send(file); }); }
Styling
apps/web/src/styles.css:
css:root { font-family: Inter, system-ui, sans-serif; color: #0b1b2b; background: #f6f8fb; } body { margin: 0; } .page { min-height: 100vh; display: grid; place-items: center; padding: 24px; } .card { width: 100%; max-width: 640px; background: white; border-radius: 16px; padding: 24px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); } .dropzone { margin-top: 16px; padding: 32px; border: 2px dashed #8aa0b3; border-radius: 12px; text-align: center; cursor: pointer; } .dropzone.active { border-color: #11ab5a; background: #effcf4; } .status-row { margin-top: 16px; } .progress-bar { margin-top: 12px; width: 100%; height: 12px; border-radius: 999px; background: #e5e7eb; overflow: hidden; } .progress-bar-fill { height: 100%; background: #11ab5a; width: 0%; transition: width 0.2s ease; } .error { margin-top: 16px; color: #b91c1c; } .uploaded { margin-top: 20px; padding: 16px; border: 1px solid #d1d5db; border-radius: 12px; background: #fafafa; }
This UI is small, but it includes the states that actually matter during debugging and testing:
- requesting upload URL
- uploading file
- finalizing upload
- complete
- explicit error state
That state visibility matters. When uploads fail in CI or staging, you need to know whether the break is in your backend signing logic, browser PUT request, storage policy, or finalize/readback step. A single “Upload failed” message makes debugging harder than it needs to be.
CORS: the thing that mysteriously fails only after deploy
If you run this locally with MinIO and browser-to-storage uploads, you need CORS on the bucket.
For MinIO, you can configure this with the MinIO client (mc) or use a preconfigured environment in CI. If you skip this, your backend tests may pass and your Playwright test may still fail at the actual browser PUT.
A minimal CORS policy should allow:
- origin:
http://127.0.0.1:5173 - method:
PUT,GET - headers:
Content-Type
If you’re using AWS S3 directly, the bucket CORS JSON would look like this:
json[ { "AllowedHeaders": ["Content-Type", "*"], "AllowedMethods": ["PUT", "GET", "HEAD"], "AllowedOrigins": ["http://127.0.0.1:5173"], "ExposeHeaders": ["ETag"] } ]
This is one of the classic examples of why isolated API testing is not enough. Your backend can happily generate a valid presigned URL while the real browser request still fails because the storage bucket rejects the cross-origin PUT.
Playwright: prove the file is actually retrievable
Now the important part.
We are not going to mock the upload request. We are not going to stub the presigned URL. We are not going to stop at checking the success status text.
We’re going to upload a real file and assert that the rendered image loads from the returned retrieval URL.
Playwright config
At the repo root, create playwright.config.ts:
tsimport { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests", timeout: 60_000, use: { baseURL: "http://127.0.0.1:5173", headless: true }, webServer: [ { command: "npm --workspace apps/api run dev", url: "http://127.0.0.1:4000/health", reuseExistingServer: !process.env.CI, timeout: 60_000 }, { command: "npm --workspace apps/web run dev", url: "http://127.0.0.1:5173", reuseExistingServer: !process.env.CI, timeout: 60_000 } ] });
E2E test
tests/upload.spec.ts:
tsimport { test, expect } from "@playwright/test"; import path from "node:path"; test("uploads a real file and verifies it is retrievable", async ({ page }) => { await page.goto("/"); const filePath = path.resolve("tests/fixtures/sample.png"); await page.getByTestId("file-input").setInputFiles(filePath); await expect(page.getByTestId("status")).toHaveText("Upload complete", { timeout: 30_000 }); await expect(page.getByTestId("uploaded-result")).toContainText("sample.png"); const img = page.getByTestId("uploaded-image"); await expect(img).toBeVisible(); const naturalWidth = await img.evaluate((node) => { const image = node as HTMLImageElement; return new Promise<number>((resolve, reject) => { if (image.complete) { resolve(image.naturalWidth); return; } image.onload = () => resolve(image.naturalWidth); image.onerror = () => reject(new Error("Image failed to load")); }); }); expect(naturalWidth).toBeGreaterThan(0); });
This last assertion matters more than it looks.
If you only verify the DOM contains an <img> tag or a success message, you can still miss:
- broken download URLs
- expired read signatures
- object stored with unexpected content type
- object not actually present when rendered
- bucket access problems
By waiting for the image to load and asserting naturalWidth > 0, you’re checking that the browser successfully retrieved and decoded the uploaded file.
That is much closer to the user workflow than a toast assertion.
Add a real fixture file
Put an actual PNG at tests/fixtures/sample.png.
Do not generate random bytes and call it a PNG. Use a valid file. That sounds obvious, but test suites are full of “fixture” files that don’t reflect the content type they claim to represent. Then teams wonder why image processing or previews behave differently in production.
CI setup with MinIO
Now let’s run the whole thing in CI.
The critical point: CI should stand up the storage dependency and run the browser test against the real workflow.
GitHub Actions workflow
.github/workflows/ci.yml:
yamlname: ci on: push: pull_request: jobs: e2e-upload: runs-on: ubuntu-latest services: minio: image: minio/minio:latest ports: - 9000:9000 env: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin options: >- --health-cmd "curl -f http://127.0.0.1:9000/minio/health/live || exit 1" --health-interval 5s --health-timeout 5s --health-retries 20 command: server /data env: CI: true S3_ENDPOINT: http://127.0.0.1:9000 S3_REGION: us-east-1 S3_ACCESS_KEY_ID: minioadmin S3_SECRET_ACCESS_KEY: minioadmin S3_BUCKET: uploads steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - name: Install dependencies run: npm install - name: Install Playwright browsers run: npx playwright install --with-deps chromium - name: Configure MinIO bucket CORS run: | curl -O https://dl.min.io/client/mc/release/linux-amd64/mc chmod +x mc ./mc alias set local http://127.0.0.1:9000 minioadmin minioadmin ./mc mb -p local/uploads || true cat > cors.json <<'EOF' { "CORSRules": [ { "AllowedOrigins": ["http://127.0.0.1:5173"], "AllowedMethods": ["PUT", "GET", "HEAD"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3000 } ] } EOF ./mc anonymous set download local/uploads ./mc cors set local/uploads cors.json - name: Run Playwright tests run: npm run test:e2e - name: Upload Playwright report on failure if: failure() uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report
A couple of practical notes:
- We explicitly configure the bucket before tests run.
- We install only Chromium for speed.
- We keep the storage endpoint local and deterministic.
- We don’t rely on an external cloud account to verify a basic product workflow.
That last one is big for developer productivity. If your end-to-end test depends on an external AWS account, a flaky VPN, or organization-wide shared infrastructure, people stop trusting failures. Reliable debugging starts with reliable test environments.
What silently breaks if you don’t verify the retrieval step
This is the part teams underestimate.
An upload flow can look healthy while still failing in ways your users notice immediately.
1. Content type mismatch
Backend signs for image/png. Frontend uploads with application/octet-stream or image/jpeg.
Result:
- PUT may fail outright with signature mismatch.
- Or object may upload but downstream preview/processing breaks.
A mocked test never catches this. A real browser PUT does.
2. CORS misconfiguration
Backend creates valid URLs. Browser can’t use them.
Server-side tests say everything is green. Real users get blocked by the browser. Only a real browser test sees that.
3. Signature expiry under real timing
Locally the upload starts immediately. In CI, the runner is slower, startup takes longer, or retries happen.
A 15-second signature that seemed fine in dev suddenly becomes flaky. If your test mocks the PUT, you’ll never see it.
4. Storage read-after-write assumptions
Your UI finalizes too early or your processing pipeline assumes the object is immediately available for subsequent steps.
Even when storage is strongly consistent, app-level races still happen: background jobs, delayed metadata writes, CDN propagation, or separate processing steps.
5. Post-upload processing gaps
The PUT succeeded, but your application never indexed the asset, generated a thumbnail, or associated the object with the record users actually view.
This is why “S3 got the bytes” is not the same as “the feature works.”
6. Broken retrieval URLs
Your upload path is correct, but your read URL generation is wrong: wrong bucket, wrong key, wrong host, expired URL, wrong ACL assumptions, broken proxy route.
A success toast will never catch that.
Make the failure modes easy to debug
When you start testing the real workflow, failures become more useful if you expose the right signals.
A few practical recommendations:
Keep status transitions explicit
Our UI says:
- Requesting upload URL
- Uploading file
- Finalizing upload
- Upload complete
When CI fails, that instantly narrows the search space.
Return structured errors from finalize
Don’t just say 400 Bad Request. Tell the caller whether:
- the upload record doesn’t exist
- the object wasn’t found
- the size mismatched
- processing failed
That turns debugging from guesswork into diagnosis.
Log storage key, content type, and size
On the backend, log these fields for both create and finalize steps. That gives you the evidence to compare frontend intent against storage reality.
Preserve Playwright traces
When the workflow breaks, traces and screenshots are often faster than reading CI logs.
You can extend the Playwright config like this:
tsuse: { baseURL: "http://127.0.0.1:5173", trace: "retain-on-failure", screenshot: "only-on-failure", video: "retain-on-failure" }
That’s not glamorous, but it’s one of the highest-leverage debugging improvements you can make.
A few hardening improvements for production
The example here is intentionally minimal. In a real product, I’d add the following.
Validate allowed file types and sizes before signing
Don’t sign arbitrary uploads. Validate:
- MIME type
- extension
- max size
- authenticated user/project ownership
Store and verify checksums
For higher confidence, send a checksum from the client and verify it during finalize or processing.
Use multipart uploads for large files
For anything substantial, single PUT uploads are not enough. Use multipart upload with resumability and better retry behavior.
Add retry logic around finalize
If your storage or processing path has a small propagation delay, an immediate finalize can race. A short bounded retry with backoff is often more realistic than assuming every object is instantly ready for follow-up work.
Route retrieval through your app when needed
Presigned GET URLs are fine for many cases. But if your product needs authorization checks, transformations, auditability, or stable URLs, a backend retrieval route may be the better contract.
Test failure paths on purpose
Don’t only test the happy path. Add Playwright coverage for:
- expired presigned URL
- oversized file rejection
- wrong content type rejection
- finalize failure after successful upload
- interrupted network during PUT
That’s where reliability work starts paying for itself.
Why this matters more now
AI-assisted development has changed the shape of software delivery.
Teams are shipping more code, faster, with more generated glue code, more API integrations, and more “looks right” implementations. That increases the chance of workflow-level bugs: the kind where each isolated piece seems fine, but the actual user journey fails.
This is why traditional testing starts to break down.
- Unit tests prove functions behave under controlled inputs.
- API tests prove endpoints return expected responses.
- CI/CD proves a pipeline executed.
None of those, on their own, prove the product works.
For file uploads in particular, the dangerous gap is between “the backend generated a URL” and “the user can use the uploaded file in the product.” That gap includes browser behavior, storage configuration, object metadata, state transitions, and retrieval.
If you care about reliability and developer productivity, test the workflow that users actually depend on.
Not because end-to-end testing replaces everything else. It doesn’t.
But because some failures only exist at the edges between systems, and uploads are almost all edges.
A practical testing strategy for upload features
If I were putting this into a real team workflow, I’d split coverage like this:
Unit tests
Use them for:
- file validation logic
- reducer/state transition logic
- storage key generation helpers
- finalize response shaping
Fast and cheap.
API integration tests
Use them for:
- create upload endpoint validation
- presigned URL generation contract
- finalize logic against test storage
These are useful, but still not enough.
Browser end-to-end tests
Use them for the critical user workflow:
- select or drag a real file
- upload through the browser
- finalize
- verify retrievability/renderability
This is your confidence layer.
Optional synthetic checks in staging
If uploads are core to the business, run a scheduled workflow that uploads a known fixture in staging and confirms retrieval and processing. That catches environment drift that code review won’t.
Tooling tradeoffs
A few quick opinions based on what tends to work.
Why Playwright here
Playwright is a good fit because it handles browser automation cleanly, starts local apps easily, and gives you traces when debugging CI failures.
Could Cypress do this? Yes.
But Playwright’s multi-service setup and failure artifacts tend to fit this kind of CI/CD workflow very well.
Why MinIO instead of mocking S3
Because mocking S3 is exactly how teams miss the bugs they care about.
Use MinIO for local and CI verification. It’s fast, deterministic, and close enough to reveal the important integration failures.
Why not test against AWS directly in CI
You can, but it raises the operational cost of every PR. Shared accounts, secrets, naming collisions, cleanup, rate limits, and external flakiness all reduce signal quality.
For most teams, verify the workflow locally with MinIO and reserve direct cloud checks for a smaller set of environment validation tests.
Wrap-up
A file upload feature is not done when the button works, the endpoint returns 200, or the toast says success.
It’s done when a real user can provide a real file, your browser code can upload it using a real presigned URL, your backend can verify and finalize it, and the product can actually retrieve and use the file afterward.
That sounds obvious. But a lot of testing and CI/CD practice still stops too early.
If you implement only one change from this article, make it this: add one end-to-end test that uploads a real file and verifies it is retrievable.
That single test closes a surprisingly large reliability gap.
It catches the problems that slip through mocked requests, isolated API tests, and optimistic UI assertions. It improves debugging because it shows where the workflow breaks. And it increases developer productivity because you stop arguing about whether the feature “should work” and start verifying whether it actually does.
That’s the standard worth holding for modern testing: not just that code paths execute, but that user workflows survive contact with reality.
