A developer recently asked a great question on Stack Overflow: how do you use heic2any in a Next.js project without adding ~600KB to the client bundle?
They'd tried next/dynamic, await import() inside a function, even moving it to an API route. Nothing worked; the bundle stayed bloated.
I run ConvertPrivately, a set of 250+ browser-based conversion tools where files never leave the user's device. We use heic2any extensively for HEIC-to-JPG/PNG/WebP conversion, and we hit exactly this problem. Here's what we learned.
Why heic2any is 600KB (and why tree-shaking won't help)
heic2any bundles libheif compiled to WebAssembly. That WASM binary is ~500KB, and it's the HEIC decoder; you can't shake it out. If your bundler sees import('heic2any') anywhere in the dependency graph, it includes that chunk.
The problem isn't the dynamic import syntax. The problem is when the bundler resolves it.
Why await import() inside a function still bloats the bundle
This is the pattern most people try first:
async function convertHeic(file: File) {
const heic2any = (await import('heic2any')).default;
return heic2any({ blob: file, toType: 'image/jpeg' });
}
It looks like it should work; the import only runs when the function is called, right?
Not quite. Webpack (and Turbopack) perform static analysis at build time. They see the import('heic2any') string, resolve the module, and create a chunk for it. That chunk becomes part of the route's chunk group. Depending on your framework's prefetching strategy, it may get preloaded even before the user interacts with anything.
next/dynamic has the same issue; it's designed for React components, not arbitrary libraries. It wraps a component in Suspense, but the underlying chunk is still in the build graph.
Pattern 1: Web Worker (best for production)
The most reliable approach is to move the conversion into a dedicated Web Worker. Workers are separate entry points; the bundler emits them as independent chunks that are never preloaded or merged into your route's JS.
// lib/heic-worker.ts
self.onmessage = async (e: MessageEvent) => {
const { buffer, toType, quality } = e.data;
const heic2any = (await import('heic2any')).default;
const blob = new Blob([buffer]);
const result = await heic2any({ blob, toType, quality });
const output = Array.isArray(result) ? result[0] : result;
const ab = await output.arrayBuffer();
self.postMessage({ buffer: ab, type: output.type }, [ab]);
};
// hooks/useHeicConverter.ts
export async function convertHeic(file: File): Promise<Blob> {
const worker = new Worker(
new URL('../lib/heic-worker.ts', import.meta.url)
);
const buffer = await file.arrayBuffer();
return new Promise((resolve, reject) => {
worker.onmessage = (e) => {
resolve(new Blob([e.data.buffer], { type: e.data.type }));
worker.terminate();
};
worker.onerror = reject;
worker.postMessage(
{ buffer, toType: 'image/jpeg', quality: 0.92 },
[buffer] // transferable - zero-copy
);
});
}
The key line is new URL('../lib/heic-worker.ts', import.meta.url). This tells webpack to emit the worker as a separate entry point. The main bundle stays completely clean.
Capping parallelism
One thing we learned the hard way: the WASM HEIC decoder is memory-hungry. If a user drops 20 photos at once and you spin up 20 workers, the tab will crash on mobile devices.
We cap concurrent workers at 2:
const MAX_PARALLEL = 2;
const queue: Array<() => Promise<void>> = [];
let running = 0;
async function enqueue(task: () => Promise<void>) {
if (running >= MAX_PARALLEL) {
await new Promise<void>(resolve => queue.push(resolve));
}
running++;
try {
await task();
} finally {
running--;
queue.shift()?.();
}
}
This keeps memory under control even during batch conversion of entire camera rolls.
Pattern 2: Manual chunk isolation with Vite/Rollup
If you're using Vite (we are), you can tell Rollup to isolate heic2any into its own chunk that's only loaded when actually imported:
// vite.config.ts
export default defineConfig({
build: {
modulePreload: false, // critical — prevents eager preloading
rollupOptions: {
output: {
manualChunks: {
'vendor-heic': ['heic2any'],
'vendor-pdf': ['pdfjs-dist'],
'vendor-xlsx': ['xlsx'],
},
},
},
},
});
The modulePreload: false is crucial. Without it, Vite injects <link rel="modulepreload"> tags that defeat the purpose of code splitting. With it disabled, the vendor-heic chunk only loads when your code actually calls import('heic2any').
In Next.js specifically, you don't have direct Rollup config access the same way, which is why the Web Worker pattern (Pattern 1) is more reliable.
Pattern 3: Script injection (simplest, least elegant)
If you just need it to work and don't care about type safety:
async function loadHeic2Any() {
if ((window as any).heic2any) return (window as any).heic2any;
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js';
script.onload = () => resolve((window as any).heic2any);
script.onerror = reject;
document.head.appendChild(script);
});
}
Zero bundle impact. The ~600KB loads from a CDN only when a user uploads a HEIC file. Downsides: no types, CDN dependency, harder to test.
Bonus: do you even need to convert?
Before loading 600KB of WASM, check if the browser can handle HEIC natively:
function canRenderHeic(): Promise<boolean> {
return new Promise(resolve => {
const img = new Image();
img.onload = () => resolve(img.width > 0);
img.onerror = () => resolve(false);
// 1x1 HEIC test image (base64)
img.src = 'data:image/heic;base64,AAAAHGZ0eXBoZWlj...';
});
}
// Usage:
const nativeSupport = await canRenderHeic();
if (nativeSupport) {
// skip conversion entirely — Safari, Chrome 130+
} else {
const converted = await convertHeic(file);
}
Safari has supported HEIC natively for years. Chrome added support in version 130 (October 2024). If your users are mostly on modern browsers, you might be able to skip the conversion entirely for a growing percentage of them.
The bigger picture
This isn't unique to heic2any. Any large WASM library; ffmpeg.wasm (25MB), tesseract.js (15MB), pdfjs-dist (2MB); has the same bundling challenge. The patterns above work for all of them:
- Web Workers for true isolation from the main bundle
- Manual chunks + disabled preload for Vite/Rollup projects
- Script injection as a quick escape hatch
The general principle: if a library is > 100KB and only used for a specific user action, it should never be in your initial bundle. Structure your code so the bundler can't even see it until the user needs it.
I built ConvertPrivately: 250+ file conversion tools that run entirely in the browser. HEIC, PDF, images, audio, data formats; all client-side, no upload, no account. The architecture decisions in this post come from building and optimizing that platform.
Top comments (0)