Custom Loaders and Asset Handling in esbuild

Custom loaders govern the byte-level ingestion, parsing, and serialization of non-JS assets, the layer below dependency-graph orchestration and chunk splitting. While the esbuild & Turbopack Workflows overview covers the build orchestration model, this guide drills into how esbuild maps file extensions to built-in loaders and how onResolve/onLoad plugin hooks override that mapping. It targets frontend engineers and build/tooling developers wiring deterministic, high-throughput asset pipelines on esbuild >= 0.25.0, with Node 20+.

esbuild ships eight built-in loaders — js, jsx, ts, tsx, json, text, base64, dataurl, file, copy, binary, and empty — and resolves each import by extension unless a plugin intercepts first. Everything downstream of resolution depends on getting that one decision right, so the diagram below traces the full path from a file path to emitted output.

esbuild loader resolution path A file path is matched against the loader map, falls through built-in loaders or is intercepted by onResolve and onLoad plugin hooks, then is serialized to output. Import path e.g. logo.svg onResolve hook? rewrite path / tag namespace Loader map .ext to built-in loader Built-in loaders jsx / ts / text dataurl / file binary / copy onLoad hook return contents + loader Output bundle + assets no match match Plugins run before the loader map; the map runs before file emission.
Figure: esbuild resolves each file path through optional onResolve/onLoad plugin hooks, falling back to the extension-to-loader map.

Prerequisites

Pin the toolchain before reproducing anything below. The plugin API surface (onResolve, onLoad, build.initialOptions) has been stable since 0.17, but the binary loader and context() watch API matter here, so use a current release:

# esbuild 0.25.x, Node 20+
npm install --save-dev esbuild@0.25.5
node -e "console.log(require('esbuild').version)"   # -> 0.25.5

A plugin is a plain object with a name and a setup(build) function. Inside setup, you register callbacks keyed on a filter regex (applied in Go, not JS — it must be a real RegExp, not a string) and an optional namespace.

Loader Resolution Order

esbuild decides how to handle a module in a fixed order, and misplacing logic across these stages is the most common source of “my loader never runs” bugs:

  1. onResolve callbacks run first, in plugin-registration order, until one returns a non-null result. A result may rewrite path, set a namespace, or mark the import external. Anything tagged with a custom namespace will only be seen by onLoad callbacks registered for that same namespace.
  2. The loader map (build.initialOptions.loader, or the --loader:.ext=... CLI flag) maps the resolved file’s extension to a built-in loader. This is where .png -> dataurl or .glsl -> text live.
  3. onLoad callbacks run for any resolved path whose filter/namespace matches. Returning { contents, loader } overrides the map entirely; returning nothing falls back to it.

The practical rule: use onResolve to route (rename, namespace, externalize) and onLoad to transform (produce contents). Returning a loader from onLoad tells esbuild how to parse the string or Uint8Array you produced — return loader: 'js' for generated module source, loader: 'binary' for raw buffers.

Configuration and CLI Reference

The simplest customization needs no plugin at all. Map extensions to built-in loaders directly:

// esbuild.config.mjs — esbuild 0.25.x, Node 20+
import { build } from 'esbuild';

await build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outdir: 'dist',
  // Extension-to-loader map. Each value is a built-in loader name.
  loader: {
    '.png': 'dataurl',   // inline small images as data: URIs
    '.woff2': 'file',    // copy to outdir, import resolves to the public path
    '.glsl': 'text',     // import shader source as a JS string
    '.wasm': 'binary',   // import as a Uint8Array
    '.svg': 'dataurl',   // overridden below by a plugin when ?inline is used
  },
});

The equivalent on the CLI, useful in CI smoke tests:

# esbuild 0.25.x — same loader map via flags
esbuild src/index.ts --bundle --outdir=dist \
  --loader:.png=dataurl \
  --loader:.woff2=file \
  --loader:.glsl=text \
  --loader:.wasm=binary

When the transform needs logic — reading the file, rewriting it, or producing a virtual module — register a plugin. This one routes .proto schema files through a namespace so they never collide with node_modules resolution, then emits a JS module:

// proto-loader.ts — esbuild 0.25.x, Node 20+
import type { Plugin } from 'esbuild';
import { readFile } from 'node:fs/promises';

export const protoLoader: Plugin = {
  name: 'proto-loader',
  setup(build) {
    // 1. onResolve: tag .proto imports with a private namespace so the
    //    default file-system resolver does not also try to handle them.
    build.onResolve({ filter: /\.proto$/ }, (args) => ({
      // Resolve relative to the importer so nested imports work.
      path: new URL(args.path, `file://${args.importer}`).pathname,
      namespace: 'proto',
    }));

    // 2. onLoad: only fires for the 'proto' namespace. Read the raw schema,
    //    wrap it as a default export, and tell esbuild to parse it as JS.
    build.onLoad({ filter: /.*/, namespace: 'proto' }, async (args) => {
      const raw = await readFile(args.path, 'utf8');
      return {
        contents: `export default ${JSON.stringify(raw)};`,
        loader: 'js',
        // Declare the file as a watched dependency so context() rebuilds
        // when the .proto source changes on disk.
        watchFiles: [args.path],
      };
    });
  },
};

For binary assets, return the buffer directly with loader: 'binary' rather than base64-encoding it yourself; esbuild generates the most compact import wrapper:

// wasm-loader.ts — esbuild 0.25.x, Node 20+
import type { Plugin } from 'esbuild';
import { readFile } from 'node:fs/promises';

export const wasmLoader: Plugin = {
  name: 'wasm-loader',
  setup(build) {
    build.onResolve({ filter: /\.wasm$/ }, (args) => ({
      path: new URL(args.path, `file://${args.importer}`).pathname,
      namespace: 'wasm-binary',
    }));

    build.onLoad({ filter: /.*/, namespace: 'wasm-binary' }, async (args) => {
      const buffer = await readFile(args.path); // Buffer, not string
      return { contents: buffer, loader: 'binary', watchFiles: [args.path] };
    });
  },
};

Step-by-Step: Wiring and Verifying a Plugin

  1. Register the plugin. Add it to the plugins array of your build() call and run a one-off build:
    node esbuild.config.mjs
  2. Confirm the loader fired. Emit a metafile and inspect which inputs were processed:
    esbuild src/index.ts --bundle --metafile=meta.json --outdir=dist
    node -e "const m=require('./meta.json'); console.log(Object.keys(m.inputs).filter(k=>k.includes('proto')))"
    The .proto paths should appear under meta.json inputs, each tagged with the proto: namespace prefix.
  3. Enable watch mode to validate invalidation. Use context() rather than the removed watch: true option:
    // watch.mjs — esbuild 0.25.x
    import { context } from 'esbuild';
    import { protoLoader } from './proto-loader.ts';
    const ctx = await context({ entryPoints: ['src/index.ts'], bundle: true, outdir: 'dist', plugins: [protoLoader] });
    await ctx.watch();
  4. Edit a source asset and confirm a rebuild fires. Because the onLoad result declared watchFiles, touching the .proto file triggers a single incremental rebuild rather than a no-op.

Debugging and Failure Modes

Plugin never runs

A filter that is a string instead of a RegExp silently matches nothing — esbuild compiles the regex in Go and rejects non-regex filters at registration. Confirm with --log-level=debug, which traces every resolver decision. Misordered fallbacks typically add 120–180 ms to cold starts in monorepos.

Namespace collisions

If two plugins register onLoad for the same namespace, only the first non-null result wins, and the second appears to do nothing. Give each plugin a distinct namespace (proto, wasm-binary) and never reuse the default file namespace for transformed output.

Stale watch rebuilds

If editing an asset does not trigger a rebuild, the onLoad result is missing watchFiles. esbuild only watches files it resolved through the module graph; assets read manually inside a callback must be declared explicitly or context().watch() will not see them.

Wrong loader for output

Returning contents as a string with loader: 'binary' corrupts the output, and a Uint8Array with loader: 'js' throws a parse error. Match the loader to the JS type you return: string to js/ts/text, buffer to binary/file.

Performance Impact and Measurement

Synchronous onLoad hooks execute in under 2 ms per module on current hardware. The cost is dominated by I/O and serialization, not the hook dispatch. Two measurements worth wiring into CI:

  • Metafile dedup. Parse metafile.outputs to detect duplicate asset inclusions; pruning them typically trims final bundle size by 12–18%.
  • Heap ceiling. Unbuffered readFileSync on assets over 5 MB spikes heap by 40 MB+ per 100 assets. Prefer the async readFile and let esbuild’s dataurl/file loaders handle large media instead of inlining manually.
# Baseline timing for an asset-heavy build — esbuild 0.25.x
time esbuild src/index.ts --bundle --loader:.glsl=text --log-level=warning
# Expect < 800 ms for 500+ asset imports on M-series silicon.

Compatibility Matrix

esbuild Node binary loader context() watch Notes
0.25.x 20, 22 yes yes Current; recommended baseline
0.21–0.24 18, 20 yes yes watch: true option removed in 0.17 already
0.17–0.20 16, 18 0.20+ only yes binary loader added in 0.20.0
< 0.17 14, 16 no no (watch: true) Old plugin/watch API; avoid

In-Depth Guides