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+.
The problem exists because a bundler that only understood JavaScript would force every non-JS import — a shader, a WebAssembly module, a Protocol Buffers schema, an SVG icon — through an ad-hoc preprocessing step outside the build graph. That is exactly the failure mode a loader layer removes: without it, an image import either throws (No loader is configured for ".png" files) or gets shelled out to a separate script whose output the bundler never sees, which means no content hashing, no tree-shaking of the reference, and no watch invalidation when the asset changes. The loader is the seam where a foreign byte stream becomes a first-class node in the module graph, and everything the bundler does afterward — deduplication, chunking, hashing, minification — depends on that node being correct.
Where this sits in the pipeline matters. Resolution decides which file an import string points to; loading decides what that file becomes in the graph; chunk splitting and hashing operate on the result. Get the loader wrong and the error surfaces two stages downstream, as a corrupt asset or a duplicated payload, far from the misconfigured extension that caused it. That distance between cause and symptom is why loader bugs are disproportionately expensive to debug, and why the rest of this guide is organized around confirming each stage did what you intended before moving to the next.
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.
Each built-in loader corresponds to a distinct serialization strategy, and choosing among them is the single decision that determines whether an asset is inlined into the JS bundle or emitted as a sidecar file. The js, jsx, ts, and tsx loaders parse the input as source and hand its AST to the bundler, so their output participates in tree-shaking and minification like any hand-written module. The json loader parses and re-emits an object literal, which lets esbuild dead-code-eliminate unused top-level keys. The text loader wraps the raw bytes as a UTF-8 string export, which is what you want for shader source, SQL, or a template. The base64 and dataurl loaders both inline the bytes — base64 as a bare string, dataurl as a fully-formed data: URI with a guessed MIME type — trading a larger JS payload for one fewer network request. The file and copy loaders take the opposite trade: they write the asset to outdir under a (usually hashed) name and resolve the import to that public path, keeping the JS bundle small at the cost of a second request. binary decodes the bytes into a Uint8Array at runtime, which is the correct shape for feeding a WebAssembly.instantiate call or a codec. empty discards the contents entirely and is the honest way to stub a side-effect-only import you never want in the graph. The rule of thumb that follows from the mechanics: inline (dataurl/base64/binary) below roughly 4 KB where the request-count saving beats the ~33% base64 inflation, and emit (file/copy) above it.
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.
The lifecycle is worth internalizing because it explains several otherwise-baffling behaviors. setup(build) runs exactly once per build context, synchronously, at the moment esbuild processes the plugins array — before any file is read. The callbacks you register inside it are not invoked then; they are stored and fired later, potentially thousands of times, as the resolver walks the graph. So any expensive one-time work (reading a config file, spinning up a compiler, opening a database of pre-computed hashes) belongs directly in the setup body, not inside the hot onResolve/onLoad callbacks where it would repeat per module. The filter regex is the throttle that keeps those callbacks cheap: esbuild evaluates it in Go against the raw path before it crosses the boundary into your JavaScript callback, so a tight filter (/\.proto$/) means the JS function is only ever called for paths that already matched, and a loose one (/.*/) pays a context-switch on every import in the build. Scope the filter as narrowly as the routing allows; reserve /.*/ for the namespace-guarded onLoad case where a prior onResolve has already restricted what reaches it.
filter is compiled in Go — a string filter silently matches nothing, the most common "my plugin never runs" bug.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.
The ordering within each stage is deterministic and worth stating precisely, because “which plugin wins” is not a coin flip. For a given path, esbuild runs registered onResolve callbacks in the order the plugins appear in the plugins array, and it stops at the first callback that returns a non-undefined object. A callback that inspects the args and returns nothing yields control to the next one, which is how you compose several plugins that each claim only their own file types. Once a path is resolved, onLoad callbacks are consulted in the same registration order, again short-circuiting on the first non-empty return. There is no merging: the winning callback’s contents and loader are the whole answer, and later callbacks for the same path never run. This is precisely why namespace collisions (two onLoad handlers guarding the same namespace) silently drop the second handler — it is not a bug, it is the first-wins contract doing what it says.
Caching cadence sits underneath all of this. In a plain build() call every callback fires once, top to bottom. Under context() with watch(), esbuild keeps the resolved graph warm and only re-runs onResolve/onLoad for the inputs whose declared dependencies changed — which is the entire reason watchFiles exists. If a callback reads a file that esbuild did not itself resolve (a sibling .d.ts, a schema included by the one you loaded), that file is invisible to the incremental engine unless you list it in watchFiles. The invalidation unit is the individual onLoad result, not the whole build, so a correctly-declared plugin rebuilds one module in single-digit milliseconds while an under-declared one appears frozen until you restart the process.
onResolve, transform in onLoad.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
Two subtleties in that map decide correctness, not just size. First, dataurl guesses the MIME type from the extension, so .svg becomes image/svg+xml and an unknown extension falls back to application/octet-stream; if you inline a font or a custom binary through dataurl and the browser refuses it, the wrong MIME guess is usually why, and the file loader (which sets the type via the served response headers instead) is the fix. Second, the file loader’s emitted name is governed by --asset-names, which defaults to [name]-[hash] — the hash is content-derived, so the same bytes always produce the same filename and a changed byte always produces a new one. That determinism is what makes file safe behind a far-future Cache-Control header; dataurl has no such filename and instead rides the cache lifetime of the JS chunk it was inlined into, which is the hidden cost of inlining a large asset that rarely changes.
The --loader: CLI flags and the loader object are exactly equivalent — the CLI form is parsed into the same map — so a CI smoke test built from flags exercises the identical code path as the config file. The one thing the map cannot express is per-import behavior: every .svg in the map gets the same loader. The moment you need import icon from './logo.svg?inline' to inline while import url from './logo.svg' emits a file, the map is out of road and you need a plugin, because only a callback can read the query string on args.path.
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],
};
});
},
};
Three details in that plugin carry the correctness. The onResolve callback rebuilds an absolute path with new URL(args.path, \file://${args.importer}`)rather than trustingargs.pathverbatim, because inside a nested importargs.pathis relative to the importer, and skipping this step makes deeply-imported schemas resolve against the process working directory instead — the classic "works from the entry point, breaks two levels deep" bug. Tagging the result withnamespace: 'proto’removes the path from esbuild's normal filesystem resolver entirely, so esbuild will not additionally try to treat the schema as a JS module or walknode_moduleslooking for it. TheonLoadcallback then guards on that same namespace withfilter: /.*/, which is safe here precisely because the namespace has already narrowed the input to paths this plugin resolved. It reads the raw schema, embeds it with JSON.stringify(which escapes quotes and newlines so the generated source is valid), declaresloader: 'js’so esbuild parses the wrapper as a module, and lists the source inwatchFilesso an edit to the.proto` invalidates just this input. Drop any one of those four and the plugin either mis-resolves, double-processes, produces syntactically broken output, or goes stale under watch.
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] };
});
},
};
Note that readFile without an encoding argument returns a Node Buffer, which is already a Uint8Array subclass, so esbuild’s binary loader accepts it without a copy. Passing 'utf8' here would hand back a string, and a string with loader: 'binary' is the corruption path described later — the bytes get re-encoded through UTF-8 and any value above 0x7F is mangled. The asymmetry to remember: string loaders want strings, binary/file want buffers, and the two are never interchangeable even though JavaScript will happily let you pass the wrong one.
When a Plugin Is the Wrong Tool
Reach for the loader map, not a plugin, whenever the transform is “inline this extension” or “copy this extension” with no branching. A map entry stays entirely on esbuild’s native Go hot path and never pays the cost of crossing into a JavaScript callback, so on an asset-heavy build the difference between --loader:.png=file and a hand-written file-emulating plugin can be tens of milliseconds of pure overhead for zero added capability. A plugin earns its keep only when the decision requires running code: reading the file to derive its output, branching on a query string, generating a virtual module that has no file on disk, or consulting external state such as a manifest. If you catch yourself writing an onLoad that does nothing but readFile and return the bytes with a fixed loader, delete it and add a map entry — you have reimplemented a built-in loader in slower JavaScript. The other anti-pattern is a plugin that shells out to a separate CLI per module; that reintroduces exactly the un-tracked preprocessing step loaders were meant to eliminate, and its output escapes both hashing and watch invalidation.
Content Hashing and Cache-Busting Emitted Assets
When a plugin emits a sidecar file rather than inlining, the emitted name must be content-derived or the whole caching story collapses. esbuild’s own file loader does this for you via --asset-names=[name]-[hash], but a plugin that writes assets itself (for example, one that transcodes an image before emitting it) has to reproduce the same discipline: hash the output bytes, not the source path, so that a byte change forces a new URL and an unchanged asset keeps its URL across builds. The following plugin resolves an .asset import to a virtual module whose default export is the public URL, and it hashes the post-transform bytes so the manifest is stable and deployable behind an immutable cache header:
// hashed-asset-loader.ts — esbuild 0.25.x, Node 20+
import type { Plugin } from 'esbuild';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { join, basename, extname } from 'node:path';
export function hashedAssetLoader(outDir = 'dist/assets'): Plugin {
return {
name: 'hashed-asset-loader',
setup(build) {
build.onResolve({ filter: /\.asset$/ }, (args) => ({
path: new URL(args.path, `file://${args.importer}`).pathname,
namespace: 'hashed-asset',
}));
build.onLoad({ filter: /.*/, namespace: 'hashed-asset' }, async (args) => {
const bytes = await readFile(args.path);
// Hash the OUTPUT bytes so identical content yields an identical URL.
const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 8);
const name = basename(args.path, extname(args.path));
const file = `${name}-${hash}${extname(args.path)}`;
await mkdir(outDir, { recursive: true });
await writeFile(join(outDir, file), bytes);
return {
contents: `export default ${JSON.stringify('/assets/' + file)};`,
loader: 'js',
watchFiles: [args.path],
};
});
},
};
}
The load-bearing choice is createHash('sha256').update(bytes) over the content, truncated to eight hex characters — long enough that a collision across a realistic asset set is astronomically unlikely, short enough to keep URLs readable. Hashing the path instead of the bytes would defeat cache-busting entirely, because the URL would stay fixed while the content changed, and users would be served stale assets until their cache expired on its own. The writeFile runs inside onLoad, which means it happens once per unique input per build; if the same asset is imported from ten modules, esbuild’s own caching ensures onLoad fires once, so the file is written once. This is also why the emit belongs in onLoad and not in onEnd — onLoad is deduplicated per input, whereas onEnd would need you to track uniqueness yourself.
Step-by-Step: Wiring and Verifying a Plugin
watchFiles confirms the incremental rebuild path works.- 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. Watch the timing esbuild prints: a warm incremental rebuild driven by onewatchFilesentry should complete in single-digit milliseconds, whereas a rebuild that re-processes the whole graph (the symptom of a missing declaration forcing a cold restart) takes as long as the initial build. If the number does not drop on the second edit, the invalidation is not wired.
The metafile in step 2 is the ground truth for “did my loader actually run”, and it repays learning to read directly. Every input esbuild processed appears as a key under metafile.inputs, and a namespaced input is prefixed with its namespace and a colon (proto:/abs/path/schema.proto). Its value records bytes (the size esbuild saw after your onLoad returned) and imports (what that input pulled in). If a path you expected is missing from inputs, the loader never fired — the import was resolved by something else or tree-shaken away before load. If it is present but its bytes is the raw source size rather than your transformed size, the onLoad matched but returned nothing and fell through to the map. Reading those two fields distinguishes “my plugin did not run” from “my plugin ran but returned the wrong thing”, which are different bugs with different fixes.
Debugging and Failure Modes
Plugin never runs
The symptom is that the build succeeds but your transform never appears to have executed — the asset either throws a “No loader configured” error or comes through unmodified. The overwhelmingly common root cause is a filter that is a string instead of a RegExp: esbuild compiles the regex in Go and, given a string, matches nothing rather than throwing, so the callback is registered but never fires. A close second is a filter that is a valid regex but too specific — anchored with ^ against a path that arrives absolute, or matching .svg but not .svg?inline once query strings enter. The fix is to widen the onResolve filter to the extension alone and do the finer discrimination inside the callback, where you have the full args. Confirm by running with --log-level=debug, which traces every resolver decision and will show the path being handled by the default resolver instead of your namespace; if your namespace never appears in that trace, the filter is the culprit. As a side effect, a misordered fallback where a broad plugin shadows a specific one typically adds 120–180 ms to cold starts in monorepos, because every import pays for a JS callback that returns nothing.
Namespace collisions
The symptom is that one of two loaders silently produces nothing while the other works, and swapping their registration order swaps which one breaks. The root cause is the first-wins contract: if two plugins register onLoad for the same namespace, esbuild takes the first non-empty result and never consults the second. This is not diagnosable from the output alone because there is no error — the losing plugin simply had its return value discarded. The fix is to give each plugin a distinct namespace (proto, wasm-binary, hashed-asset) so their onLoad handlers never contend, and never reuse the default file namespace for transformed output, since that is the namespace esbuild’s own filesystem resolver populates. Confirm by checking metafile.inputs for the namespace prefix on the affected path — if it carries the other plugin’s namespace, you have found the collision.
Stale watch rebuilds
The symptom is that editing an asset under ctx.watch() produces no rebuild, or rebuilds only after you restart the process. The root cause is an onLoad result missing watchFiles: esbuild only watches files it resolved through the module graph, so any asset you read manually inside a callback is invisible to the incremental engine unless you declare it. This bites hardest when a loader reads more than the resolved file — a schema that #includes another schema, a config that references a sibling — because the primary file is watched but its dependency is not, and edits to the dependency are lost. The fix is to add every path the callback touched to watchFiles, not just the entry path. Confirm by editing the asset and watching esbuild’s rebuild timing: a wired dependency produces a fast incremental rebuild on save, while an unwatched one produces nothing until restart.
Wrong loader for output
The symptom is either a hard parse error (Unexpected "\x00") at build time or, worse, a silently corrupt asset that only fails at runtime when the browser tries to decode it. The root cause is a mismatch between the JavaScript type of contents and the declared loader: a Uint8Array with loader: 'js' throws because esbuild tries to parse raw bytes as source, and a string with loader: 'binary' corrupts because the string is re-encoded through UTF-8, mangling every byte above 0x7F. The fix is mechanical — match the loader to the type you return: strings go to js/ts/text, buffers go to binary/file. The subtle case is a buffer you accidentally stringified by passing an encoding to readFile; the value looks fine in a log but is already lossy. Confirm by checking the emitted bytes against the source with a hash: if sha256 of the input and the emitted file output differ for an asset you only meant to copy, a stringify crept in.
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.
The reason async readFile matters more than it looks is concurrency, not per-call speed. esbuild dispatches onLoad callbacks in parallel across its worker pool, so an async callback that awaits I/O yields the thread and lets other modules load while the disk seeks. A readFileSync call blocks that worker for the whole read, serializing what should overlap; on a build with hundreds of assets the difference compounds into hundreds of milliseconds even though each individual readFileSync is fast. The heap spike from readFileSync on large media is the same mechanism seen from the memory side: the synchronous read holds the entire buffer resident with no backpressure, so a hundred concurrent 5 MB reads can transiently pin half a gigabyte. Letting the native file loader stream the asset avoids ever materializing it in the JS heap at all.
Put both measurements in CI as assertions, not observations. A build step that parses metafile.outputs, sums the sizes, and fails the job when total emitted bytes cross a threshold turns a gradual asset-size regression into a red build the day it happens, rather than a surprise discovered in production a month later. Pair it with a wall-clock ceiling on the build itself so that an accidental switch from a native loader to a hand-written plugin — the kind of change that quietly adds a JavaScript callback to every import — shows up as a timing regression in review instead of a slow erosion nobody attributes to a single commit.
Comparison with Turbopack Loaders
The mental model transfers to Turbopack but the machinery does not. esbuild’s loader is a synchronous-or-async function that returns bytes and a loader name, and its incremental story is coarse: context() re-runs onLoad for changed inputs against an in-memory graph that lives only for the process’s lifetime. Turbopack instead models every transform as a node in a persistent, content-addressed computation graph, so an unchanged asset’s transform result is recalled from cache across process restarts, not just within one watch session — the trade is a heavier engine for durable incrementality. The practical consequence for anyone porting a loader: an esbuild plugin that reads undeclared sibling files “gets away with it” in a one-shot build and only breaks under watch, whereas the equivalent in Turbopack must declare its inputs to the graph or the cache will hand back stale output even on a cold start. Declaring every touched path — watchFiles in esbuild, tracked reads in Turbopack — is the discipline that makes a loader portable between the two. The Turbopack Incremental Compilation guide covers that caching model in depth; treat esbuild’s watchFiles as the minimal, in-memory version of the same idea.
Compatibility Matrix
context() watch replaced the removed watch: true option back at 0.17.| 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.