Converting 100 MB in a static page without a server
There are thirty-nine converter and utility pages on this site, and every one of them converts in the page. No upload, no round trip, no server. Each page is a static HTML file that a CDN serves, and the conversion happens between the moment you drop a file and the moment a download appears.
That constraint is easy to state and produces a specific set of engineering problems: what to load and when, where to draw the size line, how to hand a file off to a different page, and how to keep thirty-nine pages from each shipping the whole application.
Why not a framework
A converter page is a heading, some prose, a drop zone, an options row and a result panel. React and its runtime would be the largest thing on the page by a wide margin, for a UI with perhaps six pieces of state.
These pages are also frequently somebody's entire visit. They searched for "csv to parquet", they arrived, they have one file, and they will be gone in ninety seconds. Spending a hundred kilobytes of framework before they can drop the file is the wrong trade. So the widget is plain TypeScript compiled to plain DOM calls, and the core module imports nothing that pulls in a conversion library.
The type module says this out loud, because it is the sort of property that erodes:
/**
* QuickConvert shared types.
*
* Nothing here imports a runtime dependency — the whole file compiles away,
* so it is safe for the widget core (which must stay tiny) to import it.
*/
Nothing loads until a file exists
The conversion libraries are not small. SheetJS for Excel, jsPDF for PDF output, pdf.js for PDF input, Apache Arrow and a Parquet writer, tesseract for OCR, js-yaml, and a DuckDB build for the Parquet paths. Loading any of that on page view would be indefensible for a visitor who is only reading the FAQ.
So the registry entry for each pair holds a function, not an import:
/**
* Pair registry: everything a converter page needs to configure the widget.
*
* Every `loadEngine` is a dynamic import, so nothing heavier than this file
* is fetched until a file is actually in hand.
*/
And the loader caches the promise rather than the result, with a deliberate detail:
const engineCache = new Map();
export function loadEngine(config) {
let pending = engineCache.get(config.id);
if (!pending) {
pending = config.loadEngine().catch((err) => {
engineCache.delete(config.id);
throw err;
});
engineCache.set(config.id, pending);
}
return pending;
}
Caching the promise means two rapid drops share one network request rather than racing. Deleting the entry on rejection means a failed load, from a dropped connection say, is retried on the next attempt instead of being cached as a permanent failure. Both of those are one line each and both were bugs before they were features.
The 100 MB line
/** Past this the browser tab is the wrong tool; hand off to the full editor. */
export const MAX_BYTES = 100 * 1024 * 1024;
This is a policy limit, not a technical wall, and I would rather say which. During a conversion the widget holds the input bytes, the parsed representation and the output string at the same time. For a text-to-text conversion that is roughly three copies plus the parser's own overhead, and past a hundred megabytes that triple is where tabs start dying on the sort of machine people actually have.
The refusal is written to be useful rather than apologetic:
if (file.size > MAX_BYTES) {
return {
ok: false,
message: `That file is ${formatBytes(file.size)}. This in-page converter tops out at ` +
`${formatBytes(MAX_BYTES)}. The full editor streams files this size without ` +
`loading them all at once.`,
offerEditor: true,
};
}
It states the actual size of your file, the actual cap, and what to do instead. The
offerEditor flag turns
the error panel into a handoff, which is the subject of two sections down.
Validating a file you have not read
Before the bytes are read, a
File gives you three
things: a name, a size, and a MIME type that the operating system guessed. That is a thin
basis for rejecting somebody's file, and the validator is written to be generous.
An extension match passes. Failing that, a MIME match passes. Failing that, a textual MIME type on a converter that accepts pasted input passes, with a warning. A file with no extension and no MIME at all is attempted anyway for text formats. Only when none of those hold does it refuse.
There is a comment in that function that I like more than the code:
// Nothing has been read at this point — File.size and File.type are all
// there is — so the old "its contents look right" claimed a check that
// never happened.
The warning text used to say the contents looked right. They had not been looked at. That is a small dishonesty and it is exactly the kind that erodes a user's ability to trust any message the tool shows them, so it got rewritten to say what actually happened: this file does not end in .csv, and it is being read as plain text anyway.
The check that does need bytes lives separately, and runs at the one place the text has been decoded:
/**
* Text-level checks that need the actual content.
*
* validateFile cannot do this: it is synchronous and File exposes nothing but
* a name, a size and a MIME type until the bytes are read. So the engines run
* it at the one place the bytes have been decoded (csvShared.readText), and
* the paste path runs it here.
*/
What it catches is a binary file renamed to .csv. Without that check the user gets a preview table full of replacement characters and no explanation.
Handing a file to another page with no server
"Open in the full editor" is a plain navigation to
/app. No shared
JavaScript context survives that, so the bytes need a store both sides can reach.
/**
* Handoff staging: carry the widget's source file across to /app.
*
* A dedicated IndexedDB database keeps this independent of the app's own
* persistence layer — the converter pages must not pull in Dexie or the
* store to hand off one file.
*
* The record is single-slot and consumed on read: whoever reads it deletes
* it, so a refresh after the import cannot import the same file twice.
*
* No imports on purpose — the widget entry pays for whatever this costs.
*/
Four decisions in one comment. A separate database, so a converter page does not pull in the app's persistence library for a single blob. Single slot, because there is exactly one pending handoff and a queue would be a queue to garbage-collect. Consumed on read, so a refresh on the app side does not re-import. No imports, because this file is part of the widget's cost.
And an expiry:
/** Long enough for a slow /app boot, short enough that a stale tab is never resurrected. */
export const HANDOFF_MAX_AGE_MS = 5 * 60 * 1000;
Without it, opening a converter page a week later could resurrect a file you had forgotten about, which is both surprising and a small privacy problem. The record validator checks the shape, the blob type and the age, and returns null rather than throwing, so a malformed or stale record is simply a missing handoff.
The intent travels too. The URL carries
?from=csv&to=xlsx,
so the workbench opens with the export target preselected and the product tour suppressed.
Someone who arrived with a conversion in mind should not be shown an onboarding tour.
The bug that only existed in the built output
This is the one that cost the most time.
The widgets worked perfectly in development and crashed on the deployed build. The difference is bundling: development serves unbundled modules, production runs everything through Rollup.
Rollup emits a small module of CommonJS interop helpers, used by every packaged CommonJS dependency: papaparse, SheetJS, jsPDF. Left to the default chunking rules, that helper module lands in the shared vendor chunk. Which means every one of those libraries imports the vendor chunk. Which means a converter page's dynamic import of one engine statically pulls the entire application vendor graph, and then executes it in a circular order that crashes during initialization.
The fix is three lines in the manual chunking function, with the reasoning kept next to them:
// Vite's dynamic-import preload helper must not be welded into the
// vendor chunk: any page whose entry contains an import() would then
// statically pull the entire app vendor bundle (~732 KB gzip).
if (id === "\0vite/preload-helper.js") return "vite-preload";
// CommonJS interop helpers must not live inside the app vendor
// chunk: every split CJS package (papaparse, xlsx, jspdf) imports
// them, and a converter page would statically drag in the whole
// vendor graph — which also executes circularly and crashes.
if (id === "\0commonjsHelpers.js") return "cjs-helpers";
Around that sit a dozen more rules, each pulling one heavy library into its own chunk: DuckDB, PostHog, Arrow, ag-grid, ECharts, SheetJS, avsc, fflate, fzstd, sql.js, papaparse, js-yaml, jsonpath-plus, jsPDF, pdf.js. Every one of them exists because something that should have been lazy turned out not to be.
One of them is a decision in the other direction, and it is worth quoting because it is the exception:
// tesseract.js deliberately stays in vendor: its own deps land in
// the vendor catch-all, and splitting it created a circular
// vendor <-> vendor-tesseract execution order that crashed CJS
// interop at init. It is only ever loaded via the OCR dynamic
// import, so it never burdens converter pages from vendor.
Splitting it was the theoretically correct thing and it broke. Leaving it costs nothing in practice because the only path that reaches it is already dynamic. Recording why is what stops someone re-doing the split in six months.
The general lesson: a page's real dependency graph is a property of the build output, not of the import statements you wrote. Any architecture that depends on lazy loading needs its chunk boundaries verified against the built bundle, because the failure mode is not a missing feature, it is a page that quietly ships a megabyte it never uses.
Errors people can act on
A tool with no server has nowhere to hide a stack trace, so error messages are part of the product. Engine authors write good ones; everything else goes through a normalizer.
export function readableError(err) {
if (err instanceof DOMException && err.name === "AbortError") return "Conversion cancelled.";
const message = err instanceof Error ? err.message : String(err);
if (!message || message === "undefined") {
return "Something went wrong reading that file. Try the full editor, which reports more detail.";
}
if (/Failed to fetch|NetworkError|dynamically imported module/i.test(message)) {
return "The converter could not finish downloading. Check your connection and try again.";
}
return sanitizeParserNoise(message);
}
The network branch matters specifically because of the lazy loading. A user on a flaky connection drops a file, the engine import fails, and the raw error mentions a dynamically imported module, which means nothing to them. Naming it as a download problem with a retry is the difference between a bounce and a conversion.
And sanitizeParserNoise
exists because browsers do something unhelpful with XML errors: Chrome and Safari render a
whole apology page inside a
<parsererror>
element, and its text content arrives wrapped in headings written for a browser window.
The sanitizer strips the frame and keeps the fact. It carefully preserves newlines while
collapsing other whitespace, because some engines write line-scoped reports and the error
panel renders them as pre-wrapped text.
What I would change
The engines run on the main thread. A hundred-megabyte conversion will make the tab unresponsive for its duration, and there is a progress callback but no true off-thread execution. The workbench does parse on a worker, which is part of why the handoff exists, but the widgets should not need the handoff for that reason. Moving them to a worker is the next real piece of work here and it is not done.
Beyond that, the architecture has held up better than I expected. Static HTML per page, one registry describing every pair, dynamic imports for everything heavy, and an explicit size cap with a real handoff past it. Thirty-nine pages, and the shared code between them is small enough to read in a sitting.