When we started building Rowslint, the very first architectural decision was the one that ended up shaping everything else: where does the data live during an import?
We chose the user’s browser. Every byte of every file. By default, no row of customer data ever touches our infrastructure.
This isn’t a feature we bolted on for compliance teams. It’s the foundation of the product. Below is why we made that choice, what it costs us, and what it gets you.
The default approach (and its hidden costs)
The standard CSV import pipeline looks something like this:
- The user uploads a file to your server.
- Your server stores it (S3, disk, somewhere temporary).
- Your server parses, validates, and transforms the file.
- Your server inserts the rows.
- Your server cleans up the artifacts.
This works. It’s also the source of most of the production incidents we’ve watched companies hit when they build CSV import in-house.
Storage liability. The moment a user-uploaded file lands on your disk, you own it for compliance purposes. GDPR, CCPA, HIPAA, SOC 2 — they all treat personal data on your servers as data at rest the second you receive it. You need encryption. You need retention policies. You need audit logs. You need a defensible answer to “what happens if your S3 bucket gets misconfigured tomorrow.”
Tail latency. A 100 MB CSV with 2 million rows takes 4–8 seconds to upload, plus another 3–5 seconds for the server to parse and validate. Your user sits on a loading spinner for ~12 seconds, watching nothing.
Cost. Server-side parsing scales linearly with your customer base. Every CPU cycle spent on regex validation is a cycle you’re paying for, on infrastructure you have to monitor.
Surface area. A file upload endpoint that accepts arbitrary content from authenticated users is a meaningful security boundary. It’s where ZIP-bombs, malformed XLSX files, and macro-injection attempts arrive. We didn’t want to be in that business — and we didn’t want our customers to be either.
Our architecture, in one diagram
┌────────────────────────────────────────────────────┐
│ User's browser │
│ │
│ ┌────────┐ ┌──────────┐ │
│ │ File │ ────────────────────→ │ Validator│ │
│ │ picker │ │ (Zod) │ │
│ └────────┘ └──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Mapped, typed│ │
│ │ row stream │ │
│ └──────────────┘ │
│ │ │
└───────────────────────┼────────────────────────────┘
│ (only the clean rows)
▼
Your webhook / API
The flow:
- The browser reads the file with the File API and streams it through a chunked parser —
papaparsefor CSV, a streamingexceljsbuild for XLSX. - Each row passes through your declared validators in real time.
- Clean rows are sent directly to your webhook from the browser.
Rowslint never sees the file. Rowslint never sees the rows. The only thing we do server-side is authenticate the API key and bill for the import.
Three things this gets you for free
1. Tail latency is nearly invisible
Because parsing happens in a Web Worker on the user’s machine, the main thread stays interactive. Users see rows populate the preview table within ~140 ms of selecting a file (we measured this on a 2019 MacBook Air with a 50,000-row dataset).
There’s no upload phase, because there’s nothing to upload until validation passes. By the time the data hits your webhook, it’s already typed and clean.
2. The compliance story writes itself
When we’re asked “where do you store user data?”, we have a one-word answer: nowhere. This isn’t aspirational. It’s what the network tab shows.
For your team, this means you don’t need a Data Processing Agreement covering Rowslint as a sub-processor. We aren’t one. We’re a JavaScript bundle.
3. Self-hosting becomes cheap
Because the heavy lifting happens client-side, the only thing to self-host is a small auth/bill server (~50 MB of memory, single binary). Our enterprise customers running air-gapped Kubernetes deployments have the entire stack in two pods.
What changed when we moved validation client-side
The non-obvious win wasn’t privacy. It was the validation feedback loop.
In a server-side world, when a user uploads a file with 12,000 invalid rows, here’s what happens:
- They wait for the upload (slow).
- They wait for parsing (slower).
- They get a single error response: “12,038 errors. Please fix and re-upload.”
- They open the file. They guess what’s wrong. They edit. They upload again.
- Repeat 4 more times.
This is where 80% of import support tickets come from. Users can’t iterate fast enough to converge on a clean file.
In the browser, every keystroke can re-run the validator. We surface errors inline, on the row, with a hint of what’s wrong:
const rules = {
email: z.string().email(),
phone: z.string().regex(/^\+?[1-9]\d{7,14}$/),
plan: z.enum(["free", "pro", "enterprise"]),
mrr: z.number().positive(),
created_at: z.coerce.date(),
};
The user sees "-32.00" is not a positive number — did you mean to flip the sign? next to the offending cell. They fix it in place. They never re-upload a file. Support volume drops by an order of magnitude.
When server-side parsing still makes sense
We’re not religious about this. There are three scenarios where browser-side parsing breaks down:
- Files larger than ~2 GB. Browser memory budgets are real. We support streaming up to 2 GB on most devices, but past that, you want the file to land somewhere with more headroom.
- Cross-row validation against your production database. “No two rows have the same email” works fine in-memory. “No row references a
customer_idthat doesn’t already exist in your production database” needs the server. - Resume-after-close. If a user closes their laptop in the middle of a 1.5 GB import, all their progress is gone. For these flows, we offer opt-in ephemeral storage with a 24-hour TTL — clearly labeled, off by default.
For everything else — which is roughly 95% of the imports we’ve watched in production — the browser is the right place to parse.
What this means for your security posture
If you’re shipping a CSV import flow today and your security team hasn’t reviewed it yet, here are the questions they’re going to ask:
- Where does the uploaded file land?
- How long does it stay there?
- Who has access?
- What happens if the file is malicious?
- What’s our breach blast radius?
With a server-side importer, you have to have answers — and you have to maintain them as your infrastructure evolves. With a third-party importer that uploads to their servers, you’ve added a sub-processor and a DPA review to your timeline.
With Rowslint, the answers are: nowhere, never, no one, the browser sandbox handles it, zero. That’s not a clever phrasing. It’s just what the architecture forces to be true.
Try it
Browser-side parsing isn’t an upsell. It’s how the product works on every plan, including the free tier.
If you want to see it for yourself, the integration is two lines:
import { launchRowslint } from "@rowslint/importer-js";
launchRowslint({
apiKey: process.env.ROWSLINT_API_KEY,
config: { templateKey: "customers_v3" },
onComplete: (rows) => save(rows),
});
The first row of clean, validated data hits your webhook before the user finishes their second sip of coffee.