Most file upload features look done long before they’re actually reliable.
In code review, the happy path looks clean: the frontend asks the backend for a presigned URL, uploads the file, stores a record, and shows a success toast. Unit tests pass. CI/CD goes green. Everyone moves on.
Then real users show up.
They upload a 48 MB PDF on hotel Wi‑Fi. They close the tab right after the progress bar hits 100%. Your storage write succeeds but your database status never updates. A malware scan takes longer than expected and the UI keeps saying “uploaded” even though the file is still blocked from use. The browser retries one request but not the next. A stale presigned URL expires. The user drags in the same filename twice. Your backend marks the upload as ready before processing completes. Support tickets appear, and nobody can reproduce the issue with unit tests.
This is where a lot of traditional testing breaks down. Upload flows are workflow problems, not just helper-function problems. They span browser behavior, storage, backend state machines, asynchronous jobs, and the UI states that tie them together. If you only test code paths in isolation, you get false confidence.
In this walkthrough, we’ll build a file upload flow the way production systems usually need it:
- direct-to-storage upload using presigned URLs
- persisted upload records with explicit states
- asynchronous malware scanning / processing
- retry handling and progress UI
- a status polling endpoint
- end-to-end Playwright tests that upload real files, simulate network failures, and verify scan transitions
- CI config so the whole workflow is exercised automatically
The stack is intentionally simple:
- Node.js + Express backend
- local disk used as “object storage” for demo purposes
- a fake background scanner worker
- vanilla browser-side JavaScript
- Playwright for verification
You can swap the storage layer for S3, GCS, or R2 later. The important part is the workflow shape.
What we’re building
We’ll implement this lifecycle:
- User selects a file.
- Frontend requests an upload session from the backend.
- Backend creates an upload record in
pending_uploadstate and returns a short-lived upload URL. - Frontend uploads the file directly to storage while showing progress.
- When upload completes, frontend calls
complete-upload. - Backend verifies the object exists, moves status to
uploaded, then queues async scanning. - Background worker transitions the record through
scanningto either:readyrejectedprocessing_failed
- Frontend polls the upload status and updates the UI.
- Playwright verifies the whole workflow, including bad-network and timeout conditions.
That explicit state model matters. Without it, teams usually compress multiple realities into a vague boolean like uploaded: true, which is how users end up trying to use files that aren’t actually safe or ready.
Why file upload bugs survive code review
Here’s the uncomfortable truth: file uploads often “work” as code but fail as systems.
Common failure modes:
- The file is in storage, but the DB record says
pendingforever. - The UI says success after the PUT completes, but the backend never got the completion callback.
- The scan job fails, but the UI never leaves
processing. - The upload helper retries the PUT, but not the finalize call.
- Unit tests mock storage, so they never reveal expiration, interrupted requests, or stale state.
- CI/CD runs frontend and backend tests separately, so no test verifies the user can upload a real file and see it become ready.
This is why debugging these flows is painful: the bug often lives between systems.
The fix is not “more unit tests.” The fix is to model the workflow clearly and verify it end-to-end.
Project structure
Here’s the demo layout:
txtupload-demo/ server/ index.js storage.js uploads-store.js scanner.js public/ index.html app.js tests/ upload.spec.js fixtures/ clean.txt eicar.txt package.json playwright.config.js .github/workflows/e2e.yml
Install dependencies:
bashnpm init -y npm install express cors multer uuid npm install -D playwright wait-on start-server-and-test
We’re not using a real cloud SDK here because the article is about workflow reliability, not vendor-specific setup. We’ll mimic a presigned upload URL with a one-time token endpoint. The same frontend and Playwright patterns apply to S3 presigned PUT URLs.
Backend: upload store with explicit states
Start with a tiny persistence layer. For demo purposes, in-memory state is enough.
server/uploads-store.js
jsconst { randomUUID } = require('crypto'); const uploads = new Map(); function createUpload({ filename, size, contentType }) { const id = randomUUID(); const now = new Date().toISOString(); const record = { id, filename, size, contentType, createdAt: now, updatedAt: now, storageKey: `${id}-${filename}`, status: 'pending_upload', scanResult: null, error: null, uploadCompletedAt: null, }; uploads.set(id, record); return record; } function getUpload(id) { return uploads.get(id) || null; } function updateUpload(id, patch) { const current = uploads.get(id); if (!current) return null; const updated = { ...current, ...patch, updatedAt: new Date().toISOString(), }; uploads.set(id, updated); return updated; } function listUploads() { return [...uploads.values()]; } module.exports = { createUpload, getUpload, updateUpload, listUploads, };
This state machine is intentionally boring. Boring is good. The statuses should map to real business states, not implementation details.
Recommended statuses:
pending_uploaduploadingif you track server-side startuploadedscanningreadyrejectedprocessing_failed
A lot of reliability work is just naming states correctly.
Backend: local storage adapter
Now a tiny storage layer.
server/storage.js
jsconst fs = require('fs'); const path = require('path'); const storageDir = path.join(__dirname, '..', 'storage'); if (!fs.existsSync(storageDir)) { fs.mkdirSync(storageDir, { recursive: true }); } function getFilePath(storageKey) { return path.join(storageDir, storageKey); } function writeStreamToFile(readable, storageKey) { return new Promise((resolve, reject) => { const filePath = getFilePath(storageKey); const writable = fs.createWriteStream(filePath); readable.pipe(writable); readable.on('error', reject); writable.on('error', reject); writable.on('finish', resolve); }); } function fileExists(storageKey) { return fs.existsSync(getFilePath(storageKey)); } function readFile(storageKey) { return fs.readFileSync(getFilePath(storageKey)); } module.exports = { writeStreamToFile, fileExists, readFile, };
In production, this layer would call S3/GCS. Keep the abstraction. It makes testing and debugging much easier.
Backend: fake background scanner
We need an asynchronous scanner to model the real world.
server/scanner.js
jsconst { updateUpload, getUpload } = require('./uploads-store'); const { readFile } = require('./storage'); function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function runScan(uploadId) { const record = getUpload(uploadId); if (!record) return; updateUpload(uploadId, { status: 'scanning', error: null }); await delay(1500); try { const content = readFile(record.storageKey).toString('utf8'); if (content.includes('EICAR') || record.filename.includes('virus')) { updateUpload(uploadId, { status: 'rejected', scanResult: 'malware_detected', error: 'File failed security scan', }); return; } if (record.filename.includes('fail-processing')) { updateUpload(uploadId, { status: 'processing_failed', scanResult: 'clean', error: 'Post-upload processing failed', }); return; } updateUpload(uploadId, { status: 'ready', scanResult: 'clean', error: null, }); } catch (err) { updateUpload(uploadId, { status: 'processing_failed', error: err.message, }); } } module.exports = { runScan };
This fake scanner gives us deterministic outcomes for Playwright tests.
Backend: API endpoints
Now the server itself.
server/index.js
jsconst express = require('express'); const cors = require('cors'); const crypto = require('crypto'); const { createUpload, getUpload, updateUpload, listUploads, } = require('./uploads-store'); const { writeStreamToFile, fileExists } = require('./storage'); const { runScan } = require('./scanner'); const app = express(); app.use(cors()); app.use(express.json()); app.use(express.static('public')); const uploadTokens = new Map(); function createUploadToken(uploadId, storageKey) { const token = crypto.randomBytes(16).toString('hex'); const expiresAt = Date.now() + 60 * 1000; uploadTokens.set(token, { uploadId, storageKey, expiresAt, used: false }); return token; } function getValidToken(token) { const entry = uploadTokens.get(token); if (!entry) return null; if (entry.used) return null; if (entry.expiresAt < Date.now()) return null; return entry; } app.post('/api/uploads', (req, res) => { const { filename, size, contentType } = req.body; if (!filename || !size) { return res.status(400).json({ error: 'filename and size are required' }); } const upload = createUpload({ filename, size, contentType }); const token = createUploadToken(upload.id, upload.storageKey); res.json({ uploadId: upload.id, status: upload.status, uploadUrl: `/upload/${token}`, expiresInSeconds: 60, }); }); app.put('/upload/:token', async (req, res) => { const { token } = req.params; const tokenEntry = getValidToken(token); if (!tokenEntry) { return res.status(403).json({ error: 'Invalid or expired upload token' }); } const upload = getUpload(tokenEntry.uploadId); if (!upload) { return res.status(404).json({ error: 'Upload not found' }); } try { await writeStreamToFile(req, tokenEntry.storageKey); tokenEntry.used = true; res.status(200).end(); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post('/api/uploads/:id/complete', (req, res) => { const upload = getUpload(req.params.id); if (!upload) { return res.status(404).json({ error: 'Upload not found' }); } if (!fileExists(upload.storageKey)) { return res.status(409).json({ error: 'Uploaded file not found in storage' }); } const updated = updateUpload(upload.id, { status: 'uploaded', uploadCompletedAt: new Date().toISOString(), error: null, }); runScan(upload.id); res.json(updated); }); app.get('/api/uploads/:id', (req, res) => { const upload = getUpload(req.params.id); if (!upload) { return res.status(404).json({ error: 'Upload not found' }); } res.json(upload); }); app.get('/api/uploads', (req, res) => { res.json(listUploads()); }); const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`Server listening on http://localhost:${port}`); });
A few important details here:
completeis a separate step from the direct upload.- The backend verifies the file exists before changing status.
- The scanner starts only after completion is confirmed.
- Upload token expiry is explicit and testable.
This is much safer than assuming a successful PUT means the file is fully integrated into your application.
Frontend: progress, retries, and status polling
Here’s the HTML.
public/index.html
html<!doctype html> <html> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Upload Demo</title> <style> body { font-family: Arial, sans-serif; max-width: 720px; margin: 40px auto; } .hidden { display: none; } .progress { width: 100%; height: 16px; background: #eee; border-radius: 8px; overflow: hidden; } .progress-bar { height: 100%; width: 0%; background: #11AB5A; } .status { margin-top: 16px; padding: 12px; border: 1px solid #ddd; border-radius: 8px; } .error { color: #b91c1c; } </style> </head> <body> <h1>Upload a file</h1> <input type="file" id="fileInput" /> <button id="uploadBtn">Upload</button> <div id="progressSection" class="hidden"> <p id="progressText">0%</p> <div class="progress"><div id="progressBar" class="progress-bar"></div></div> </div> <div id="statusBox" class="status hidden"> <div>Upload ID: <span id="uploadId"></span></div> <div>Status: <span id="statusText"></span></div> <div>Scan result: <span id="scanText">-</span></div> <div id="errorText" class="error"></div> </div> <script src="/app.js"></script> </body> </html>
And the app logic.
public/app.js
jsconst fileInput = document.getElementById('fileInput'); const uploadBtn = document.getElementById('uploadBtn'); const progressSection = document.getElementById('progressSection'); const progressBar = document.getElementById('progressBar'); const progressText = document.getElementById('progressText'); const statusBox = document.getElementById('statusBox'); const uploadIdEl = document.getElementById('uploadId'); const statusText = document.getElementById('statusText'); const scanText = document.getElementById('scanText'); const errorText = document.getElementById('errorText'); function setStatus(upload) { statusBox.classList.remove('hidden'); uploadIdEl.textContent = upload.id; statusText.textContent = upload.status; scanText.textContent = upload.scanResult || '-'; errorText.textContent = upload.error || ''; } function createXHRUpload(url, file) { return new Promise((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); progressBar.style.width = `${percent}%`; progressText.textContent = `${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); }); } async function retry(fn, retries = 2, delayMs = 800) { let lastError; for (let i = 0; i <= retries; i++) { try { return await fn(); } catch (err) { lastError = err; if (i < retries) { await new Promise((r) => setTimeout(r, delayMs)); } } } throw lastError; } async function pollStatus(uploadId) { for (;;) { const res = await fetch(`/api/uploads/${uploadId}`); const upload = await res.json(); setStatus(upload); if (['ready', 'rejected', 'processing_failed'].includes(upload.status)) { return upload; } await new Promise((r) => setTimeout(r, 1000)); } } uploadBtn.addEventListener('click', async () => { const file = fileInput.files[0]; if (!file) { alert('Choose a file first'); return; } progressSection.classList.remove('hidden'); progressBar.style.width = '0%'; progressText.textContent = '0%'; errorText.textContent = ''; try { const createRes = await fetch('/api/uploads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name, size: file.size, contentType: file.type, }), }); if (!createRes.ok) throw new Error('Failed to create upload'); const session = await createRes.json(); setStatus({ id: session.uploadId, status: 'pending_upload', scanResult: null, error: null, }); await retry(() => createXHRUpload(session.uploadUrl, file), 1, 1000); progressBar.style.width = '100%'; progressText.textContent = '100%'; const completeRes = await retry( () => fetch(`/api/uploads/${session.uploadId}/complete`, { method: 'POST' }), 2, 1000 ); if (!completeRes.ok) throw new Error('Failed to finalize upload'); const upload = await completeRes.json(); setStatus(upload); await pollStatus(session.uploadId); } catch (err) { errorText.textContent = err.message; statusBox.classList.remove('hidden'); statusText.textContent = 'client_error'; } });
A few strong opinions here:
- Use
XMLHttpRequestfor upload progress. For browser uploads, it’s still the practical choice unless you build around streams explicitly. - Retry carefully. Retrying a
PUTcan be safe if the upload target is one-time and your storage semantics are clear. Retrying “complete upload” also matters; many teams forget this step. - Polling is fine for this workflow. You don’t need WebSockets just to reflect scan completion.
What this catches that helper tests won’t
At this point, most teams would write unit tests around retry() and call it done.
That misses the actual workflow risks:
- Is progress rendered while bytes are in flight?
- Does a failed upload leave the record stuck in
pending_upload? - Does a successful object write transition to
uploadedonly after finalize? - Does the scan state ever become visible to the user?
- Does a rejected file actually surface a meaningful status?
- If the network dies during the upload, can the user recover?
These are integration and end-to-end concerns.
Playwright setup
Install browsers:
bashnpx playwright install
Create config.
playwright.config.js
jsconst { defineConfig } = require('@playwright/test'); module.exports = defineConfig({ testDir: './tests', timeout: 30000, use: { baseURL: 'http://localhost:3000', headless: true, }, webServer: { command: 'node server/index.js', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, }, });
Now create two fixture files:
tests/fixtures/clean.txt
txthello, this is a clean file
tests/fixtures/eicar.txt
txtX5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*
Playwright: verify the happy path
tests/upload.spec.js
jsconst { test, expect } = require('@playwright/test'); const path = require('path'); test('uploads a clean file and reaches ready state', async ({ page }) => { await page.goto('/'); await page.setInputFiles('#fileInput', path.join(__dirname, 'fixtures', 'clean.txt')); await page.click('#uploadBtn'); await expect(page.locator('#statusBox')).toBeVisible(); await expect(page.locator('#progressText')).toHaveText('100%'); await expect(page.locator('#statusText')).toContainText('uploaded'); await expect(page.locator('#statusText')).toContainText(/scanning|ready/); await expect(page.locator('#statusText')).toHaveText('ready', { timeout: 10000 }); await expect(page.locator('#scanText')).toHaveText('clean'); await expect(page.locator('#errorText')).toHaveText(''); });
This already provides more useful confidence than a dozen unit tests around fetch wrappers.
But the real value comes when you test how the workflow behaves under failure.
Playwright: validate malware rejection
Add a test for the async rejection path.
jstest('rejects a malware-like file after scanning', async ({ page }) => { await page.goto('/'); await page.setInputFiles('#fileInput', path.join(__dirname, 'fixtures', 'eicar.txt')); await page.click('#uploadBtn'); await expect(page.locator('#progressText')).toHaveText('100%'); await expect(page.locator('#statusText')).toHaveText('rejected', { timeout: 10000 }); await expect(page.locator('#scanText')).toHaveText('malware_detected'); await expect(page.locator('#errorText')).toContainText('security scan'); });
This matters because “upload succeeded” is not the same thing as “file is usable.” A lot of systems blur that distinction, which is how downstream consumers get bad data.
Playwright: simulate interrupted upload
Now let’s verify network interruption. This is where end-to-end testing starts paying for itself.
Because the upload uses XHR, we can intercept the upload route and force a failure once.
jstest('shows an error when upload is interrupted and retry cannot recover', async ({ page }) => { let requestCount = 0; await page.route('**/upload/**', async (route) => { requestCount += 1; if (requestCount === 1) { await route.abort('failed'); return; } await route.continue(); }); await page.goto('/'); await page.setInputFiles('#fileInput', path.join(__dirname, 'fixtures', 'clean.txt')); await page.click('#uploadBtn'); await expect(page.locator('#progressText')).toBeVisible(); await expect(page.locator('#statusText')).toHaveText(/pending_upload|client_error/); });
The current app retries once, so depending on timing and browser behavior, the second request may succeed. That’s actually useful information. If you want deterministic behavior, force both attempts to fail:
jstest('surfaces client_error when upload repeatedly fails', async ({ page }) => { await page.route('**/upload/**', async (route) => { await route.abort('internetdisconnected'); }); await page.goto('/'); await page.setInputFiles('#fileInput', path.join(__dirname, 'fixtures', 'clean.txt')); await page.click('#uploadBtn'); await expect(page.locator('#statusText')).toHaveText('client_error'); await expect(page.locator('#errorText')).toContainText(/Network error|Upload failed/); });
Notice what we’re testing:
- not whether
retry()increments a counter - whether the user sees a meaningful outcome when the workflow breaks
That distinction is the whole game.
Playwright: catch finalize-step failures
One of the easiest bugs to miss is a successful object upload followed by a failed finalize step. The file exists, but your app never acknowledges it.
Let’s test that.
jstest('fails visibly when finalize step breaks after upload reaches 100%', async ({ page }) => { await page.route('**/api/uploads/*/complete', async (route) => { await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Finalize failed' }), }); }); await page.goto('/'); await page.setInputFiles('#fileInput', path.join(__dirname, 'fixtures', 'clean.txt')); await page.click('#uploadBtn'); await expect(page.locator('#progressText')).toHaveText('100%'); await expect(page.locator('#statusText')).toHaveText('client_error'); await expect(page.locator('#errorText')).toContainText('Failed to finalize upload'); });
This is exactly the kind of issue code review misses. The upload helper is fine. The storage write is fine. The user still loses.
Playwright: validate state transitions, not just final state
Final-state assertions are not enough. You also want to verify the workflow actually moves through the expected states.
jstest('transitions through uploaded and scanning before ready', async ({ page }) => { await page.goto('/'); await page.setInputFiles('#fileInput', path.join(__dirname, 'fixtures', 'clean.txt')); await page.click('#uploadBtn'); await expect(page.locator('#statusText')).toContainText('uploaded'); await expect(page.locator('#statusText')).toContainText(/scanning|ready/, { timeout: 5000 }); await expect(page.locator('#statusText')).toHaveText('ready', { timeout: 10000 }); });
Why bother? Because bad UI state handling often skips intermediate states. If the browser jumps from “pending” to “ready,” maybe that’s okay. Or maybe the UI is hiding latency and masking a broken poller. State transition assertions make debugging much easier when a workflow gets flaky.
Improve determinism with test-only controls
For production code, you don’t want hidden behavior. For testing, it’s often worth adding explicit hooks.
Example: add a header or query param that tells the scanner to delay longer, force rejection, or force processing failure. That gives Playwright stable conditions instead of timing-sensitive guesses.
A simple backend extension could read:
jsconst testMode = req.header('x-test-scan-mode');
Or you can infer behavior from filename, which we did above. That’s acceptable for a demo, though I prefer explicit test controls in real systems.
The key is this: deterministic failure injection is a developer productivity multiplier. It reduces flaky tests and makes debugging faster.
CI/CD: run the actual workflow
Now wire this into CI so every PR verifies the user workflow, not just helper functions.
Add scripts to package.json:
json{ "scripts": { "start": "node server/index.js", "test:e2e": "playwright test" } }
GitHub Actions config:
.github/workflows/e2e.yml
yamlname: e2e-upload-flow on: push: branches: [main] pull_request: jobs: e2e: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 cache: npm - name: Install dependencies run: npm ci - name: Install Playwright browsers run: npx playwright install --with-deps chromium - name: Run end-to-end tests run: npx playwright test - name: Upload Playwright report if: always() uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report/
This matters more than people think. A lot of CI/CD setups test backend logic and frontend rendering separately. That’s useful, but it does not prove the workflow works.
If your PR checks don’t upload a real file and observe the final scan state, you’re still guessing.
What silently breaks if you skip workflow verification
Here’s what teams usually miss when they rely on unit tests and spot checks:
1. Orphaned files
Storage contains uploaded objects that never got finalized. You pay to store them, but the app can’t use them.
2. Orphaned records
The DB says an upload exists, but no object was ever fully written.
3. Incorrect success states
The UI reports success at 100% upload even though scanning or processing later rejects the file.
4. Retry gaps
You retry the upload, but not the create-session or finalize request. Workflow reliability is defined by the weakest step.
5. Expired upload URLs
Locally, everything is fast. In production, users wait too long, switch tabs, or resume later. Presigned URLs expire, and nobody tested the user-visible behavior.
6. Polling bugs
The backend updates status correctly, but the UI caches stale responses, stops polling too early, or never renders processing_failed.
7. Security-process blind spots
A malware pipeline rejects content, but the product layer still exposes the file because it only checked uploaded.
None of these are exotic edge cases. They’re normal production conditions.
If you switch this to real S3 presigned URLs
The architecture stays the same. The differences are implementation details:
POST /api/uploadscreates an object key and returns an S3 presigned PUT URL.- Browser uploads directly to S3.
POST /completeverifies object existence usingHeadObject.- A queue or event triggers scanning and downstream processing.
- Status polling endpoint remains unchanged.
That stability is valuable. It means your Playwright tests can keep targeting the user workflow even while infrastructure changes under the hood.
For example, your create endpoint might return:
jsres.json({ uploadId: upload.id, uploadUrl: signedUrl, method: 'PUT', headers: { 'Content-Type': contentType, }, });
The frontend doesn’t care whether the URL points to /upload/:token or AWS S3. That separation improves maintainability and debugging.
Practical testing rules for upload flows
If you’re implementing this for a real product, these are the practices I recommend.
Model state explicitly
Don’t flatten everything into uploaded: true. The user and the system need to distinguish:
- file transfer completed
- scan in progress
- file approved and usable
- file rejected
- processing failed
Test with real files
Use Playwright to set actual files on the input. Don’t stop at mocked function calls.
Verify intermediate states
Assert uploaded, scanning, and final status. This catches broken pollers and skipped transitions.
Inject failures on purpose
Abort the upload request. Fail the finalize endpoint. Force scan rejection. You need to know what users see when the workflow breaks.
Keep retries narrow and intentional
Blind retries can duplicate work or hide data consistency issues. Know which operations are idempotent.
Make CI run the end-to-end path
Not nightly. On pull requests. Reliability regresses one innocent refactor at a time.
Preserve backend observability
Even with great Playwright coverage, you still want logs and traceable upload IDs. When production breaks, correlation is everything.
A note on developer productivity
This kind of testing is not “extra QA work.” It’s core engineering work.
The fastest teams I’ve seen are not the ones with the most green unit tests. They’re the ones that can trust CI/CD because CI actually reflects user reality. That means fewer phantom regressions, faster debugging, and less time arguing over whether a bug is “frontend” or “backend.”
Reliable workflow tests improve developer productivity because they catch integration failures at the point of change, when context is still fresh.
That’s especially important now, when more code is generated quickly and reviewed at higher volume. The risk is no longer just bad logic in one function. The risk is a plausible implementation that looks complete while the real workflow is still brittle.
Wrap-up
A file upload feature is not done when the PUT request works.
It’s done when a user can select a real file, upload it under imperfect network conditions, see meaningful progress, wait through asynchronous scanning or processing, and end up with an accurate final state. And it’s really done when CI verifies that whole journey on every change.
That’s the shift teams need to make: from testing code paths to testing workflows.
The implementation in this article is simple on purpose, but the pattern scales:
- direct-to-storage upload
- explicit upload record states
- separate finalize step
- background scanning/processing
- end-to-end Playwright verification
- CI that exercises the real flow
If you adopt just one idea from this article, make it this one: stop treating upload reliability as a helper-function problem. It’s a system behavior problem. Model the states, test the workflow, and your CI/CD pipeline will start telling you something useful instead of just telling you your mocks still agree with each other.
