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:
// 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):
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<string>();
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 <= 0 || 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):
{ "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 60 Hz browser frame (16.6 ms), 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):
const DB_NAME = "offline_sheet_guard_db";
const STORE_NAME = "draft_batches";
function openDraftDb(): Promise<IDBDatabase> {
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<void> {
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-textcompanyNamecolumn 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
validateInvoiceSheetinside a Web Worker.
Top comments (1)
The column-shift case deserves more than a footnote, because it fails in the dangerous direction: "Acme, Inc." doesn't produce an error, it moves every later cell one column right, so an amount can pass validation as a seat count. Loud parse errors are annoying; silent realignment is a billing bug.
The benchmark fixture plants four errors, all in flat unquoted cells, so both the 20ms and the 4/4 catch rate are proven against the case the parser already handles - plant one quoted-comma row and the suite starts guarding the switch to RFC-4180 instead of just recommending it.
One more trap for that switch: rowNumber is curren
Comments
No comments yet. Start the discussion.