I was building a database library that needed to run SQLite in the browser. Everything worked perfectly in development. Then I tried to build for production and it all fell apart — the workers would not load, the bundlers threw cryptic errors, and I spent several days trying to work out what was actually wrong.

If you have ever tried to use web workers in a JavaScript project that has to work across different build tools, you have probably hit the same wall. The problem turned out not to be my code. It was a mismatch between how web workers are constructed and how bundlers decide what to bundle — and the fix, once I found it, was to stop doing the thing I assumed was the library's job.

Workers, briefly

A web worker is JavaScript's way of running code on a separate thread, away from the main UI thread, communicating by message passing. Think of it as a helper working in another room: your UI keeps running smoothly while the worker does the heavy lifting — database operations, image processing, complex calculations.

flowchart LR
accTitle: Messages between the UI and worker
accDescr: The main thread sends work to a worker with postMessage. The worker returns its result through onmessage.
    UI[Main thread — UI] -->|postMessage| W[Worker — heavy work]
    W -->|onmessage| UI
The main thread sends work to a worker with postMessage. The worker returns its result through onmessage.

Constructing one is meant to be straightforward:

const worker = new Worker(new URL("./worker.js", import.meta.url), {
  type: "module",
});

import.meta.url is the current module's URL, so this says "find worker.js next to this file." Clean, standard, modern JavaScript, and it works in a browser with no build step at all.

Then npm run build:

Module not found: Can't resolve '/assets/worker-xyz123.js'
import.meta.url resolution failed
Worker loading error: Failed to construct 'Worker'

Why it breaks

import.meta.url is a runtime value. Bundlers need to resolve dependencies at build time. It is a bit like giving someone directions to a place that does not exist yet.

For a bundler to handle that new Worker(new URL(...)) expression, it has to pattern-match it, pull the worker into its own bundle, hash the filename, emit it as a separate asset, and rewrite the URL to point at the emitted file. Every bundler that supports this implements it as a special case on that exact syntax — and if it cannot resolve the expression, the build simply fails with an unresolved import.

Which is fine, until your code is inside a library. Each bundler handles it differently, and that is where the chaos begins:

  • Vite in development handles import.meta.url perfectly, serving files directly from the filesystem. Everything just works.
  • Vite in production sometimes fails to resolve worker paths. Hash generation can break references and assets do not always get copied where you expect, depending on where the file sits relative to the package root.
  • Webpack requires special configuration, its import.meta.url support has never been consistent across major versions, and it often needs custom loaders.
  • Next.js is more complex again because of server-side rendering. Worker does not exist on the server at all, and the module graph gets evaluated in an environment that cannot run it, so build-time and runtime path resolution end up in conflict.

The deeper issue is this: the pattern-matching happens in the consumer's bundler, but the code being matched is inside your published package. By the time a user's bundler sees your dist/index.js, the relative path has already been resolved once, against your build layout rather than theirs.

What did not work

Attempt 1: copying the worker to public/ manually.

const worker = new Worker("/worker.js");

Fine for an application, useless for a library. It requires every consumer to copy a file by hand and keep it in sync, there are no automatic updates when the worker code changes, it breaks the moment the app is served from a subdirectory, and it does not scale across a team.

Attempt 2: bundler-specific configuration.

webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.worker\.js$/,
        use: { loader: "worker-loader" },
      },
    ],
  },
};

worker-loader solves it for Webpack. It does nothing for Vite, Rollup, esbuild, or Parcel. The configuration is complex and fragile, it differs between environments, and asking users to add loader config in order to consume a library is not a real answer.

Attempt 3: dynamic imports with query suffixes.

const workerModule = await import("./worker.js?worker");
const worker = new Worker(workerModule.default);

This is Vite-specific syntax. Other bundlers treat the suffix as part of the filename and fail to resolve it, and the behaviour still differs between development and production.

Each of these fixes one environment by hard-coding an assumption about the build. There are more environments than I have patience.

The pattern that works

After several days of this, I went and read how established database libraries handle the problem. They all do the same thing, and it took me a while to appreciate why.

They do not construct the worker. They accept one.

flowchart LR
accTitle: Who constructs the worker
accDescr: Before: the library constructs a worker, which can break in some bundlers. After: user code constructs the worker and passes it to the library.
    subgraph before["Library constructs it"]
        L1[Library] --> W1[Worker — breaks in some bundlers]
    end
    subgraph after["Caller constructs it"]
        U[User code] --> W2[Worker] --> L2[Library uses it]
    end
Before: the library constructs a worker, which can break in some bundlers. After: user code constructs the worker and passes it to the library.

The library exports two things: a class that takes a worker, and a worker entry point that can be bundled as its own module.

database-worker.js
export class DatabaseWorker {
  constructor(worker, options = {}) {
    this.worker = worker; // provided by the caller
    this.options = options;
    this.pending = new Map();
    this.worker.addEventListener("message", (event) => {
      this.handleMessage(event.data);
    });
  }
 
  query(sql, params) {
    return new Promise((resolve, reject) => {
      const id = crypto.randomUUID();
      this.pending.set(id, { resolve, reject });
      this.worker.postMessage({ type: "query", id, sql, params });
    });
  }
}
worker-entry.js
import { setupWorker } from "./worker-communication.js";
 
setupWorker();

And the consumer wires them together:

import { DatabaseWorker } from "my-database-lib";
 
const worker = new Worker(new URL("my-database-lib/worker-entry", import.meta.url), {
  type: "module",
});
 
const db = new DatabaseWorker(worker, { databasePath: "app.db" });
await db.query("SELECT * FROM users WHERE id = ?", [1]);

The new Worker(...) expression is now in the user's source, where their bundler can see it, pattern-match it, and resolve it against their own build. Which is the one place it was ever going to work reliably.

Why this works everywhere

The benefit comes entirely from the separation of concerns:

  • The user controls worker creation. They handle import.meta.url in their own environment, where it means something.
  • The library provides the worker logic. We export the code that runs inside the worker and nothing about how it gets loaded.
  • Bundlers see explicit imports. No dynamic resolution is required anywhere.
  • It works with any bundler, because the user's bundler is handling the user's own worker construction.
  • No configuration is needed. It is all standard ES modules and subpath exports.

The build config that makes this possible is just a second entry point:

vite.config.js
export default defineConfig({
  build: {
    lib: {
      entry: {
        index: "src/index.ts",
        "worker-entry": "src/worker-entry.ts",
      },
      formats: ["es"],
    },
    rollupOptions: { external: ["@sqlite.org/sqlite-wasm"] },
  },
});
package.json
{
  "exports": {
    ".": { "import": "./dist/index.js" },
    "./worker-entry": { "import": "./dist/worker-entry.js" }
  }
}

The subpath export is what lets a consumer reference the worker entry by package-relative specifier instead of digging into node_modules.

NOTE

The exact incantation on the consumer's side still varies — Vite resolves a bare specifier inside new URL(), some setups want ?url, and Next.js needs the call kept out of anything that renders on the server.

That variation is no longer your problem, and that is the entire benefit. Users write the line their own bundler documents, and the library works with all of them because it makes no assumption about any of them.

The practical difference is stark:

Before (automatic construction):
Build:        fails
Bundle size:  n/a
Runtime:      n/a
Experience:   days of debugging
 
After (explicit pattern):
Build:        ~2s
Bundle size:  ~45 KB compressed
Runtime:      database operations do not block the UI
Experience:   works without configuration

The trade

This is not free. The API is slightly worse: consumers write three lines instead of one, and they need to know a worker exists at all.

What you get for those three lines is universal compatibility with no configuration, no manually copied files, no per-environment code paths, and an API explicit enough to type properly. For a library, that trade is obviously right — one line of consumer convenience is not worth being unusable in Next.js.

For an application, where you control the build, the automatic version is fine. Know which one you are writing.

Conclusion

The lesson is simple: explicit beats magical at a boundary you do not control.

The failure here was not really technical. It was assuming a library could make a decision — where the worker file lives and how it gets loaded — that only the consumer's build has the information to make. Every workaround I tried was an attempt to guess that information, and guessing has an unbounded number of wrong answers.

Handing the decision back to the caller looks like giving up on ergonomics. It is really just putting the decision where the knowledge is, and that shape shows up well beyond web workers: dependency injection, configuration, plugin interfaces. Any time a library reaches for something only the application knows, the fix is usually to accept it as an argument rather than to guess harder.