I was building a database library that runs SQLite in the browser. It worked perfectly in development. The production build failed, and kept failing for several days in ways that made no sense.

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 thought was the library's job.

Workers, briefly

A web worker runs JavaScript on a separate thread, communicating with the main thread by message passing. It is how you do expensive work — database queries, image processing, parsing — without freezing the UI.

flowchart LR
    UI[Main thread — UI] -->|postMessage| W[Worker — heavy work]
    W -->|onmessage| UI

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, and 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.

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. Every bundler that supports this implements it as a special case on that exact syntax.

Which is fine, until your code is inside a library:

  • Vite in dev serves from the filesystem and it all works. Vite in production sometimes resolves it and sometimes does not, depending on where the file sits relative to the package root.
  • Webpack wants configuration, and its import.meta.url support has never been consistent across major versions.
  • Next.js adds server-side rendering, where Worker does not exist at all and the module graph is evaluated in an environment that cannot run it.

The deeper issue: 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

Copying the worker to public/ and referencing /worker.js. Fine for an application, useless for a library — it requires every consumer to manually copy a file and keep it in sync, and it breaks the moment the app is served from a subdirectory.

Webpack-specific configuration. worker-loader solves it for Webpack. It does nothing for Vite, Rollup, esbuild, or Parcel, and asking users to add loader config to consume a library is not a real answer.

Dynamic imports with query suffixesimport("./worker.js?worker"). This is Vite-specific syntax. Other bundlers treat it as part of the filename and fail to resolve it.

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

I went and read how established database libraries handle this. 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
    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

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.

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 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.

What you get for those three lines is that the library works in every bundler with no configuration, no copied files, and no per-environment code paths. 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.

The general lesson

Explicit beats magical at a boundary you do not control.

The failure here was not 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.