Validating 10,000-Row CSV Uploads in 20ms Before Touching Your Server (React + IndexedDB)
Watch someone import a 10,000-row billing CSV over spotty Wi-Fi on a standard web app. They pick the file, wait ten seconds for the progress bar to crawl to 100%, and get bounced back by a red toast: 400 Bad Request: Invalid amount on row 7,500. So they fix row 7,500, re-upload the entire file, and discover another typo on row 8,102. If their connection drops while they are editing rows in your UI and they refresh the page, things usually get worse. A lot of apps try to stash draft uploads in localStorage, forget that browsers cap localStorage around 5 MB and block the main thread synchronously on writes, and end up throwing QuotaExceededError right when the user needs a backup most. We can fix both problems on the frontend without pulling in a 200 KB validation library. In the companion repo (github.com/endlessfountain/react-offline-sheet-guard ), I wired up a small TypeScript preflight check that validates 10,000 CSV rows (40,000 cells) in 20.11 ms before a single byte hits the network, paired with a 45-line IndexedDB helper so multi-megabyte drafts survive offline refreshes. **Why naive duplicate checks freeze React tabs Checking whether an email looks valid or whether seatCount is an integer is cheap. The line that quietly kills browser performance on 10,000-row sheets is duplicate ID detection. When people write quick client-side validators, they often reach for Array.prototype.some() inside a row loop: typescript // Don't do this on 10k rows: O(N^2) = ~50,000,000 string comparisons const isDuplicate = previousRows.some((prev) => prev.externalId === row.externalId); At 200 rows you won't notice. At 10,000 rows, scanning previousRows on every line turns into ~50 million comparisons and locks the tab for nearly a second on mid-range laptops. Swapping that array scan for a Set keeps duplicate detection O(1) per row (src/engine/sheetValidator.ts): typescript const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export function validateInvoiceSheet(rawCsv: string) { const t0 = performance.now(); const lines = rawCsv.trim().split(/\r?\n/); const seenIds = new Set(); const validRows: InvoiceRow[] = []; const issues: CellIssue[] = []; for (let i = 1; i < lines.length; i++) { const [externalId = "", customerEmail = "", amountUsd = "", seatCount = ""] = lines[i].split(","); const id = externalId.trim(); const email = customerEmail.trim(); const amtStr = amountUsd.trim(); const seatStr = seatCount.trim(); const rowNumber = i + 1; let rowClean = true; if (!id || seenIds.has(id)) { issues.push({ rowNumber, column: "externalId", value: externalId, code: "DUPLICATE_ID", message: id ? `Duplicate invoice ID '${id}'` : "Missing invoice ID", }); rowClean = false; } else { seenIds.add(id); } if (!EMAIL_RE.test(email)) { issues.push({ rowNumber, column: "customerEmail", value: customerEmail, code: "INVALID_EMAIL", message: "Malformed billing email" }); rowClean = false; } const amt = Number(amtStr); if (!amtStr || Number.isNaN(amt) || amt 100_000) { issues.push({ rowNumber, column: "amountUsd", value: amountUsd, code: "AMOUNT_OUT_OF_RANGE", message: "Expected plain decimal 0.01..100000" }); rowClean = false; } const seats = Number(seatStr); if (!Number.isInteger(seats) || seats < 1 || seats > 5000) { issues.push({ rowNumber, column: "seatCount", value: seatCount, code: "INVALID_SEAT_INTEGER", message: "Expected whole integer 1..5000" }); rowClean = false; } if (rowClean) { validRows.push({ rowNumber, externalId: id, customerEmail: email, amountUsd: amtStr, seatCount: seatStr }); } } return { totalRows: Math.max(0, lines.length - 1), validRows, issues, elapsedMs: Number((performance.now() - t0).toFixed(2)), bytesAvoidedOnFailure: issues.length > 0 ? new TextEncoder().encode(rawCsv).byteLength : 0, }; } Notice that rowClean keeps checking all four columns on the same row instead of continue-ing after the first bad cell. If row 318 has both a broken email and "$1200" with a dollar sign, the user gets both errors at once rather than fixing the email, re-running, and getting yelled at a second time for the amount column. Running npm test in the repo generates a 418.8 KB, 10,000-row CSV with four planted errors (duplicate ID at row 50, bad email at row 500, "$1200" at row 2,500, and 3.5 seats at row 7,500): json { "rows": 10000, "valid_rows": 9996, "issues_caught": 4, "elapsed_ms": 20.11, "wasted_upload_avoided_kb": 418.8 } Twenty milliseconds is barely over a single 60Hz browser frame (16.6ms), and catching those four typos upfront avoids shipping 418.8 KB over the wire on a doomed POST request. Stashing multi-megabyte drafts in IndexedDB without extra packages Raw IndexedDB has an ugly callback API from 2012, which scares people into installing wrappers or falling back to localStorage. In practice, if all you need is saving and restoring an upload draft by key, wrapping open, put, and get in Promises takes about 35 lines (src/storage/draftDb.ts): typescript const DB_NAME = "offline_sheet_guard_db"; const STORE_NAME = "draft_batches"; function openDraftDb(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME, { keyPath: "id" }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } export async function saveDraftSnapshot(draft: SavedDraft): Promise { const db = await openDraftDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); tx.objectStore(STORE_NAME).put(draft); tx.oncomplete = () => { db.close(); resolve(); }; tx.onerror = () => { db.close(); reject(tx.error); }; }); } In src/App.tsx, useEffect subscribes to window's "online" and "offline" events to flip a live connectivity banner and checks loadDraftSnapshot("current") on mount. You can cut your Wi-Fi in Chrome DevTools (Network -> Offline), edit the 5,000-row sample, save a snapshot, reload the tab, and pick up right where you left off. Three places where this simple setup isn't enough First, client preflight checks are purely a UX and bandwidth win, not a security boundary. Keep the exact same validation rules on your backend endpoint because anyone can send malformed payloads straight to your API with curl. Second, line.split(",") only works when none of your columns legitimately contain commas inside quotes. As soon as you add a free-text companyName column containing values like "Acme, Inc.", switch the parser step to an RFC-4180 parser such as PapaParse so quoted commas don't shift your columns right by one index. Finally, while 10,000 rows finishes in 20 ms on the main thread, 150,000 rows takes around 300 ms, which is long enough to make typing feel sluggish if you validate on every keystroke. Above 25,000 rows, either debounce validation by 250ms or move validateInvoiceSheet inside a Web Worker.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to