The most common thing I do with a Parquet file is embarrassingly small:
someone sends me events.parquet and I want to know what columns it has and
eyeball twenty rows. The standard answer — spin up a notebook, import pandas,
wait — always felt like using a forklift to open a jar.
So I built ParquetKit: a viewer, SQL workbench and
set of converters for Parquet files that run 100% client-side. No upload, no
signup, no backend. This post is about the architecture decisions that made
that work.
Two engines, loaded at different times
The core trick is refusing to pick one engine.
hyparquet (~10 KB) handles first paint. When you drop a file, the viewer
needs schema, metadata and the first 50 rows — that's it. hyparquet is a pure
JavaScript Parquet reader that reads the metadata footer and only the row
ranges you actually look at, streamed with Blob.slice. Opening a 1.3 GB
file takes ~350 ms on my machine, because almost none of the file is read.
Memory stays flat when you page through it.
DuckDB-WASM handles everything heavy. SQL queries, joins across files,
and conversions (Parquet → CSV/JSON/JSONL, and CSV/JSONL → Parquet) all go through DuckDB running
in a Web Worker. It's ~30 MB of WASM, so it lazy-loads only when you actually
query or convert — the viewer never pays that cost. Files are registered with
registerFileHandle, by reference: DuckDB reads the byte ranges it needs
instead of copying the file into memory.
"No backend" as a feature, not a cost saving
The site is a Next.js static export (output: "export"). There is no server
— which means there is nowhere to upload your file to. That's not a
limitation, it's the entire privacy story: you can open DevTools, watch the
network tab, and verify that your data never leaves the machine. Once the
engine is cached, the tools keep working with the network disconnected.
This matters for the audience I built it for: people who deal with work data
they're not allowed to paste into random "free online converter" sites or AI
chatbots.
The same decision makes operations trivial. Hosting is a CDN. The error
monitoring (Sentry) scrubs file names, file contents and query strings in
beforeSend — the monitoring must not undermine the privacy promise either.
Things that bit me
LZ4 has two dialects. Parquet files write either LZ4 (legacy framed) or
LZ4_RAW. The compressor package I started with silently mis-decoded LZ4_RAW;
I swapped in lz4js and wrote round-trip tests (pyarrow writes fixtures for
every codec, the browser reads them back in CI on real Chromium).
Blob workers can't use relative URLs. DuckDB's worker is spawned from a
blob URL, so every asset reference inside it has to be absolute. Fine in dev,
broken in production — the kind of bug you only catch by running e2e against
the actual static export, which is what the CI does (Playwright against
serve out/).
Test against the deployment artifact, not the dev server. next dev
tolerates things the static export doesn't (accidental server imports, path
assumptions). Our e2e suite only ever runs against the built out/
directory, in three browsers.
Try it
- Parquet Viewer — has a one-click sample dataset if you don't have a .parquet handy
- SQL Workbench — DuckDB SQL against your local files
- Converters
It's MIT licensed — the engine layer is deliberately React-free (enforced by
ESLint) so it could become a standalone package:
https://github.com/XxxKMSxxX/parquetkit
Feedback very welcome — especially on formats you'd want next (Avro? Arrow?).
Top comments (0)