Disclosure: I am part of the team building Pixlane Media. This article focuses on a reusable browser architecture problem rather than reviewing or advertising the product.
The problem
A browser tool can keep user files on the device and still feel slow or invasive if it loads every processing engine on the first visit. Image codecs, PDF engines, OCR runtimes and AI models can turn a small application shell into a large initial download.
The opposite extreme is not ideal either. Loading everything only after the user clicks a tool keeps the landing page light, but the first operation may appear frozen unless the loading state is explicit.
The goal is therefore:
- keep the application shell small;
- load processing code only when a task needs it;
- cache reusable code, not user files;
- make loading and local state visible to the user.
1. Separate the shell from processing engines
Treat each heavy feature as an independently loaded engine. A small registry is enough for many applications:
const loaders = {
image: () => import('./engines/image.js'),
pdf: () => import('./engines/pdf.js'),
ocr: () => import('./engines/ocr.js'),
};
const loaded = new Map();
export function getEngine(name) {
if (!loaders[name]) {
throw new Error('Unknown engine: ' + name);
}
if (!loaded.has(name)) {
const promise = loaders[name]().catch((error) => {
loaded.delete(name); // allow a later retry
throw error;
});
loaded.set(name, promise);
}
return loaded.get(name);
}
The imported module can instantiate its own WebAssembly binary. Keeping the promise in the map also prevents two fast clicks from starting duplicate downloads.
Dynamic import() is documented by MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import
2. Cache executable assets, not user files
A service worker or normal HTTP cache can retain fingerprinted JavaScript, WebAssembly and model files. Source files and generated outputs should follow a different rule: do not write them to IndexedDB, Cache Storage or localStorage unless the user explicitly chooses a feature that requires persistence.
For temporary downloads, object URLs should also be released:
const url = URL.createObjectURL(resultBlob);
try {
downloadLink.href = url;
downloadLink.click();
} finally {
URL.revokeObjectURL(url);
}
Revoking an object URL is not the same as proving that every byte has been garbage-collected, but it removes an avoidable browser-managed reference. Application code should also avoid keeping File, Blob or decoded pixel buffers in global stores after a task ends.
3. Model loading as an explicit state
A delayed first action is easier to understand when the interface distinguishes these states:
- preparing the local engine;
- processing on this device;
- complete;
- failed, with a retry action.
For large assets, showing the expected download size before loading can be more honest than an indeterminate spinner. The same state can be exposed through aria-live so screen-reader users receive progress updates.
4. Keep the security boundary understandable
Local-first does not automatically mean secure. A production implementation still needs to consider:
- a restrictive Content Security Policy;
- trusted, versioned dependencies;
- cross-origin isolation when threads or
SharedArrayBufferare required; - clearing references after processing;
- avoiding telemetry that includes filenames, extracted text or document content.
The WebAssembly JavaScript API reference is available here: https://developer.mozilla.org/en-US/docs/WebAssembly/Reference/JavaScript_interface
5. Measure performance without measuring content
Useful timing can be collected without examining the user's file:
const startedAt = performance.now();
const engine = await getEngine('pdf');
const engineReadyAt = performance.now();
await engine.process(file);
const finishedAt = performance.now();
console.log({
engineLoadMs: engineReadyAt - startedAt,
processingMs: finishedAt - engineReadyAt,
});
In real analytics, bucket or aggregate values and avoid recording file names, extracted text or document identifiers.
Open design questions
We are testing these trade-offs while building Pixlane Media, where image, PDF, OCR and AI-assisted tools run in the browser. The remaining questions are mostly product decisions:
- At what asset size should a module require an explicit download prompt?
- Should frequently used engines remain cached indefinitely or expire?
- Is one “clear local data” control enough, or should users inspect each cached engine?
- Which engines, if any, deserve preloading after the browser becomes idle?
I would be interested in how other teams draw this boundary, especially on mobile devices with limited storage and unreliable connections.