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.
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:
onResolvecallbacks run first, in plugin-registration order, until one returns a non-null result. A result may rewritepath, set anamespace, or mark the importexternal. Anything tagged with a custom namespace will only be seen byonLoadcallbacks registered for that same namespace.- 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 -> dataurlor.glsl -> textlive. onLoadcallbacks run for any resolved path whosefilter/namespacematches. 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
- Register the plugin. Add it to the
pluginsarray of yourbuild()call and run a one-off build:node esbuild.config.mjs - Confirm the loader fired. Emit a metafile and inspect which inputs were processed:
Theesbuild 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')))".protopaths should appear undermeta.jsoninputs, each tagged with theproto:namespace prefix. - Enable watch mode to validate invalidation. Use
context()rather than the removedwatch: trueoption:// 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(); - Edit a source asset and confirm a rebuild fires. Because the
onLoadresult declaredwatchFiles, touching the.protofile 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.outputsto detect duplicate asset inclusions; pruning them typically trims final bundle size by 12–18%. - Heap ceiling. Unbuffered
readFileSyncon assets over 5 MB spikes heap by 40 MB+ per 100 assets. Prefer the asyncreadFileand let esbuild’sdataurl/fileloaders 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 |
Related
- esbuild & Turbopack Workflows — the parent overview covering build orchestration and where loaders sit in it.
- Writing an esbuild Plugin for Inline SVG Imports — a complete
onResolve/onLoadplugin importing.svgas a string, data URI, or JSX component. - esbuild API and CLI for Rapid Builds — the
build,transform, andcontextAPIs these plugins plug into. - Turbopack Incremental Compilation — the analogous loader and caching model for Turbopack.