esbuild Plugin Patterns
esbuild’s plugin API is deliberately tiny — two callbacks, onResolve and onLoad, plus a namespace convention — yet that surface is enough to alias paths, synthesize modules that never touch disk, inline assets, and inject values at build time. This guide covers the plugin lifecycle mechanically: how a specifier flows through resolution into loading, what a namespace actually gates, and how to keep a plugin fast and deterministic so it never poisons esbuild’s incremental rebuilds. It sits under esbuild & Turbopack Workflows; if you have not used the underlying esbuild build and transform API yet, read that first, because a plugin is just a structured way to intercept the same pipeline.
The reason this API exists at all is that esbuild’s core is written in Go and cannot call arbitrary JavaScript on the hot path. Webpack loaders and resolvers run in the same JavaScript runtime as the bundler, so they can afford to be liberal about which files they inspect; esbuild cannot, because every crossing from Go into a Node callback is a serialized inter-process message with real latency. The whole plugin design — a Go-side filter regex that gates every import before any JavaScript runs, a strict two-phase split, and an explicit fall-through protocol — is an answer to the question “how do you let users extend a Go bundler without giving up the property that made it fast in the first place.” Understanding that constraint is the difference between a plugin that adds microseconds and one that quietly turns a 40ms build into a 4-second one.
Without plugins you are limited to esbuild’s built-in loaders and its Node-style filesystem resolver, which is enough for ordinary TypeScript, JSX, JSON, and asset imports but nothing else. The moment you need a specifier that has no file behind it — a generated manifest, a virtual: entry synthesized from config, an environment shim, a bundled worker — you need a plugin, because there is no other seam in the pipeline where you can answer “what does this import point at” and “what bytes does it contain.” Plugins are also where path aliasing lives in esbuild: unlike webpack there is a built-in alias option for simple cases, but anything conditional (aliasing only in one namespace, or based on the importer) has to be an onResolve callback. Getting the plugin lifecycle right therefore governs a surprisingly large fraction of what a non-trivial esbuild config can do at all.
Prerequisites
A plugin is an object with a name and a setup(build) function; you pass it in the plugins array of esbuild.build() or esbuild.context(). Pin the version — the plugin API has been stable since 0.8 but option names on build.initialOptions shift within the 0.x range.
// package.json — verified toolchain
{
"devDependencies": {
"esbuild": "0.25.0" // plugin API stable; onStart/onEnd/onDispose available
}
}
Node 18+ is the floor for the 0.25 wrapper. A plugin runs in the same Node process as your build script, so it has full filesystem and network access — which is exactly why a plugin that reads the clock or the network non-deterministically is the fastest way to break caching, covered under Turbopack incremental compilation’s memoization rules and equally true here.
The setup(build) function runs once, synchronously or asynchronously, when a build starts, and its only job is to register callbacks — it must not do the work itself. A common mistake is putting expensive initialization (reading a config file, spawning a compiler) directly in the body of setup; that runs even for a build where nothing the plugin owns is imported. Do the cheap registration in setup and defer the expensive work into the callback or into onStart, which fires per build rather than per registration. setup may be async and may return a promise; esbuild waits for it before starting resolution, so an async setup that awaits a slow network call delays every build, plugin-relevant or not.
Plugin order matters and is not obvious. esbuild runs onResolve callbacks in the order the plugins appear in the plugins array, and the first plugin to return a non-undefined result wins — later plugins never see that specifier. onLoad is the same: first match wins. So a broad catch-all plugin placed early can starve a more specific plugin placed after it. The rule of thumb is specific plugins first, catch-alls last, and never assume your plugin is the only one registered against a namespace. Inside a single plugin, multiple onResolve registrations are tried in registration order for each import.
The build.initialOptions object is the live, mutable copy of the options passed to build(). A plugin may read it (to honor the user’s sourcemap or platform setting) and may mutate it inside setup before the build begins — for example to append to loader or force metafile: true. Mutating it after the build has started has no effect, because esbuild has already snapshotted the config into the Go side.
name and a setup function; every hook is registered from inside setup.Core mechanics: resolution, loading, and namespaces
esbuild processes every import in two phases. Resolution turns a specifier string ('./x', 'react', 'env:API_URL') into a concrete { path, namespace } pair. Loading turns that pair into { contents, loader }. A plugin’s onResolve callback runs during the first phase and may claim a specifier by returning a path and a namespace; its onLoad callback runs during the second phase and may supply the bytes for a given path-and-namespace.
The namespace is the key concept that trips people up. The default namespace is file, meaning “a real path on disk.” When your onResolve returns a custom namespace (say virtual or env), you are telling esbuild “this path does not exist on the filesystem — do not stat it; ask my onLoad for the contents instead.” An onLoad callback filters on both a path regex and a namespace, so a virtual-module plugin only fires for its own namespace and never accidentally intercepts real files.
Both callbacks take a filter (a Go-regex RegExp, applied before your JS runs, so it must be cheap and cannot use lookbehind) and an optional namespace. Returning undefined from a callback means “not mine” and lets the next plugin — or esbuild’s default — handle it. This fall-through is what makes plugins composable.
The filter is compiled by Go’s regexp package, which is RE2-based, so it has no backtracking, no lookbehind, and no backreferences. This is a feature, not a limitation: RE2 guarantees linear-time matching, which is what lets esbuild run the filter against every import in the graph without risking catastrophic backtracking. If you write a filter that JavaScript’s RegExp accepts but Go rejects (a lookbehind, a named group with unsupported syntax), esbuild throws at build start with a regex-compilation error rather than silently ignoring the plugin. Keep filters to character classes, anchors, and alternation.
How resolution works under the hood
For a single import, esbuild walks its onResolve callbacks in plugin-then-registration order, passing each an args object with the raw path, the importer (the file doing the importing), the resolveDir, the kind (import-statement, require-call, dynamic-import, entry-point, and so on), the namespace of the importer, and any pluginData threaded from a previous hook. The first callback whose filter matches and which returns a non-undefined object claims the specifier. That object’s path and namespace become the identity of the module. If no plugin claims it, esbuild falls back to its built-in resolver, which does Node-style resolution: relative paths against resolveDir, bare specifiers walking up node_modules, honoring exports/imports maps, mainFields, and conditions.
The path a plugin returns is treated literally — esbuild does not re-run resolution on it. If you return a relative path esbuild keeps it relative, which is almost never what you want; return an absolute path (via path.resolve) or set an explicit namespace so downstream code knows the string is a synthetic key rather than a filesystem location. The combined (path, namespace) pair is also the deduplication key: two imports that resolve to the same pair share one module instance in the graph, so an onResolve that returns a different path for the same logical module (say, with and without a trailing slash) will duplicate it.
How loading and caching work
Once a module has an identity, esbuild looks for an onLoad callback whose filter matches the path and whose namespace matches — both must match. The callback receives args.path, args.namespace, args.suffix (the ?query or #fragment portion, if any), and args.pluginData, and returns { contents, loader, resolveDir, pluginData, warnings, errors, watchFiles, watchDirs }. contents may be a string or a Uint8Array; loader tells esbuild how to parse it (js, ts, jsx, tsx, json, css, text, base64, dataurl, file, binary, copy, empty, or default). If no plugin loads the module and it lives in the file namespace, esbuild reads the file itself and picks a loader from the extension via the loader option map.
Caching is where determinism stops being a style preference and becomes correctness. In context() mode, esbuild keeps the parsed module graph in memory and on rebuild only re-runs onResolve/onLoad for modules whose watched inputs changed. It knows a file-namespace module changed by its mtime; it knows a plugin-loaded module changed only if you told it what to watch via watchFiles/watchDirs. A virtual module that is a pure function of initialOptions need never re-run, but one that reads config.json without listing it in watchFiles will serve stale contents after that file changes, because esbuild has no reason to invalidate it. The cache cadence, then, is: unchanged inputs are never re-loaded, changed inputs re-run the owning callback, and “changed” is defined entirely by what you declared as a dependency.
Configuration & CLI reference
The two plugins below are the templates almost every real esbuild plugin specializes. The first stops at resolution because its modules already exist on disk — it only changes where a specifier points. The second owns both phases because its modules have no on-disk source — it must answer both where and what. Keep this distinction in mind: reaching for onLoad when a plain onResolve rewrite would do is the most common way to make a plugin slower and harder to reason about than it needs to be.
A path-alias plugin with onResolve
This plugin rewrites @/… specifiers to an absolute src/ path. It resolves the alias and returns a file-namespace path so esbuild’s normal loading takes over.
// alias-plugin.mjs — esbuild 0.25.x, Node 18+
import path from 'node:path';
export const aliasPlugin = (aliases) => ({
name: 'alias',
setup(build) {
// Build one regex that matches any configured alias prefix.
const keys = Object.keys(aliases).map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const filter = new RegExp(`^(${keys.join('|')})`);
build.onResolve({ filter }, (args) => {
for (const [prefix, target] of Object.entries(aliases)) {
if (args.path === prefix || args.path.startsWith(prefix + '/')) {
const rest = args.path.slice(prefix.length);
return { path: path.resolve(target + rest), namespace: 'file' };
}
}
return undefined; // fall through
});
},
});
Three details in that plugin are load-bearing. The filter is built once, at registration, by escaping each alias key and joining them into a single anchored alternation — this keeps the Go-side gate to one compiled regex instead of one per alias, and the ^ anchor means esbuild rejects any import that does not start with a configured prefix without ever calling into JavaScript. The startsWith(prefix + '/') check prevents a prefix like @ from greedily claiming @scope/pkg; without the trailing-slash guard an alias for @ would swallow unrelated scoped packages. And returning namespace: 'file' (rather than omitting it) makes the intent explicit: this is a real path, load it normally. Because the returned path is absolute, esbuild treats it as final and does not re-resolve it, so the alias resolves in exactly one hop regardless of how deeply the aliased file is nested.
The one edge case this plugin does not handle is aliasing a bare package name to a different package (lodash → lodash-es), because the ^(@) style filter is designed for prefix folders. For that, match the exact specifier and return the resolved path of the replacement, or use esbuild’s built-in alias option, which exists precisely for one-to-one package swaps and is faster because it is handled entirely in Go. Use a plugin only when the aliasing is conditional on the importer, the namespace, or the build mode.
A virtual-module plugin with onLoad
Here onResolve claims a specifier into a custom namespace, and onLoad synthesizes its contents. Nothing is read from disk.
// virtual-plugin.mjs — esbuild 0.25.x, Node 18+
export const virtualPlugin = (modules) => ({
name: 'virtual',
setup(build) {
build.onResolve({ filter: /^virtual:/ }, (args) => ({
path: args.path.slice('virtual:'.length),
namespace: 'virtual',
}));
build.onLoad({ filter: /.*/, namespace: 'virtual' }, (args) => {
const source = modules[args.path];
if (source == null) return undefined;
return { contents: source, loader: 'js', resolveDir: process.cwd() };
});
},
});
The onLoad filter here is /.*/, which looks like the broad filter warned against elsewhere — and it is safe only because it is scoped to namespace: 'virtual'. A namespace-scoped onLoad is cheap no matter how loose its path regex, because esbuild never even considers the callback for modules outside that namespace, and the only modules in virtual are the handful this plugin’s onResolve put there. The resolveDir: process.cwd() is what lets synthesized code contain its own import statements; esbuild resolves those relative imports against that directory. Returning undefined when a key is missing lets the module fall through to a normal Could not resolve error rather than producing a confusing empty module, which is the behavior you want during development when a virtual key is misspelled.
A build-time injection plugin
The third canonical pattern is injecting values known only at build time — a git SHA, a feature-flag snapshot, the build timestamp captured once — behind an import rather than a global define. define does raw text substitution and cannot express a real module with named exports; a virtual module can. The subtlety is determinism: capture the values once in setup, not per onLoad call, so every module in the graph sees the same snapshot and the contents stay a pure function of that snapshot.
// build-info-plugin.mjs — esbuild 0.25.x, Node 18+
import { execSync } from 'node:child_process';
export const buildInfoPlugin = () => {
// Captured once at setup, so every consumer sees identical bytes.
const info = {
commit: process.env.GIT_SHA
?? execSync('git rev-parse --short HEAD').toString().trim(),
builtAt: process.env.SOURCE_DATE_EPOCH
? Number(process.env.SOURCE_DATE_EPOCH) * 1000
: Date.now(),
};
return {
name: 'build-info',
setup(build) {
build.onResolve({ filter: /^build-info$/ }, () => ({
path: 'build-info',
namespace: 'build-info',
}));
build.onLoad({ filter: /.*/, namespace: 'build-info' }, () => ({
contents: `export const commit = ${JSON.stringify(info.commit)};
export const builtAt = ${JSON.stringify(info.builtAt)};`,
loader: 'js',
}));
},
};
};
Consuming code writes import { commit, builtAt } from 'build-info' and gets tree-shakeable named exports, unlike define, which would leave process.env.GIT_SHA inlined at every use site with no way to import only one field. Note the builtAt handling: honoring SOURCE_DATE_EPOCH when it is set makes the build reproducible in CI, which is the whole point of capturing time deliberately instead of letting Date.now() leak into the graph uncontrolled. If you left the timestamp captured inside onLoad, two builds of an unchanged tree would differ, defeating content-hashed output filenames and any downstream cache keyed on the bundle.
Step-by-step workflow
- Decide which phase you need. Rewriting where a specifier points is
onResolve; supplying what a path contains isonLoad. Most non-trivial plugins use both, but a surprising number need only one. If the target already exists on disk, stop atonResolveand let esbuild’s loaders do their job; you gain nothing by also intercepting the load, and you lose esbuild’s built-in mtime-based cache invalidation for that file. Only claim the load phase when there is genuinely no file to read. - Write a cheap
filterregex. It runs in Go before your JS, on every import in the graph, so its cost is multiplied by the size of your dependency tree. Keep it anchored (^) and specific so esbuild rejects the overwhelming majority of imports without ever paying the Go-to-Node crossing. A filter of/\.svg$/is fine; a filter of/svg/will matchsvgo-utilsand every path containing those letters. When you can, pair the filter with anamespaceso the callback is only even considered for modules you already own. - Return early with
undefinedfor anything you do not own, so other plugins and the default resolver still run. This is the single most important habit for composability: a plugin that returns a result for a specifier it does not actually handle silently blocks every plugin registered after it, and the failure shows up as a missing module far from the plugin that caused it. Treatundefinedas the default and a concrete return as the exception. - Set
resolveDirononLoadresults whosecontentscontain their own imports, or esbuild cannot resolve them relative to a virtual path. A virtual module has no directory of its own, so withoutresolveDirany bare or relative import inside its generated source has no anchor to resolve against and fails. PointresolveDirat the directory those imports should be resolved from — usually the project root or the directory of the config that generated the module. - Verify with
metafile: trueand inspectinputs— your virtual and aliased modules should appear with the paths you assigned. The metafile is the ground truth for what actually entered the graph and how it was resolved; if a module you expected to be virtual shows up with a filesystem path, youronResolvedid not claim it. This is a far more reliable check than reading the bundled output, which minification and tree-shaking can make unrecognizable.
Debugging & failure modes
onLoad never fires
Symptom: you registered an onLoad, the build succeeds, but your synthesized contents never appear and the module is either empty or loaded from disk. Root cause: a namespace mismatch. The most common variant is that onResolve returned the default file namespace (because you forgot to set one) while onLoad filters on a custom namespace, so the two never meet — the path matches but the namespace does not, and both must match for the load callback to be considered. A subtler variant is that a plugin registered before yours already claimed the specifier and put it in a different namespace. Fix: ensure the namespace string is identical in the onResolve return and the onLoad filter, character for character. Confirm: log args.path and args.namespace at the top of both callbacks; if you see the resolve log but never the load log, the namespaces disagree or another plugin won resolution.
Could not resolve from inside a virtual module
Symptom: the virtual module itself loads, but an import inside its generated contents fails with Could not resolve "react" (or a relative path). Root cause: a virtual module has no directory on disk, so esbuild has no base against which to resolve the imports in its contents; resolveDir defaults to empty for plugin-loaded modules. Fix: return resolveDir from the onLoad result, pointing at the directory those imports should resolve against — process.cwd() for project-root-relative imports, or the directory of the source file the virtual module was derived from. Confirm: the error message names the specifier that could not be resolved; check whether that specifier would resolve if placed in a real file at your chosen resolveDir, and if so the resolveDir was simply missing or wrong.
Filter too broad slows the build
Symptom: adding the plugin makes an otherwise sub-100ms build take seconds, and CPU profiling shows time spent in the Node side rather than in esbuild’s Go core. Root cause: a filter: /.*/ on onResolve with no namespace forces esbuild to serialize every import in the entire graph across to your JavaScript callback, even though the vast majority are ones your plugin ignores. Each crossing is cheap individually but there can be tens of thousands of them. Fix: anchor the regex to the actual prefix or extension you own (^virtual:, \.svg$) and add a namespace when the modules live in one, so the Go side rejects non-matches for free. Confirm: count how often your callback runs by incrementing a counter and logging it in onEnd; a well-scoped plugin fires a handful of times, a broad one fires once per import.
Non-deterministic contents break rebuilds
Symptom: clean rebuilds of an unchanged source tree produce byte-different output, content hashes churn on every CI run, and a downstream cache keyed on the bundle never hits. Root cause: an onLoad that embeds Date.now(), a random id, or a live network response returns different bytes for the same inputs, so esbuild’s context() incremental rebuilds and every cache downstream see a “changed” module even though nothing you edited changed. Fix: make the plugin’s output a pure function of its declared inputs — capture volatile values once in setup, honor SOURCE_DATE_EPOCH for timestamps, and list any external files in watchFiles so real changes still invalidate. Confirm: build the same tree twice into two directories and diff -r them; identical output proves determinism, and any difference points straight at the impure value.
Performance impact & measurement
filter lets an import reach JavaScript.A well-scoped plugin adds negligible overhead because its filter regexes run in Go and reject non-matching imports before any JavaScript executes. The cost appears when a plugin either matches too broadly (calling into JS per import) or does synchronous heavy work (a large readFileSync, a child_process call) inside onLoad. Measure by running the build with metafile: true and comparing wall-clock with and without the plugin; for a watch loop, use esbuild context watch mode and watch the per-rebuild timing printed by ctx.rebuild(). Cache expensive onLoad work in a Map keyed by the resolved path, and prefer async fs.promises over sync reads so esbuild can parallelize other modules while yours loads.
The parallelism point is worth dwelling on, because it is where esbuild plugins differ most from a mental model built on synchronous webpack loaders. esbuild loads modules concurrently, and your onLoad callbacks are await-ed on the Go side, so an async onLoad that awaits I/O yields control and lets esbuild make progress on other modules in the meantime. A synchronous onLoad that blocks — execSync, readFileSync of a large file, a synchronous transpile — stalls that concurrency by occupying the Node event loop, and because Node is single-threaded the stall serializes work that could have overlapped. The practical rule: any I/O or CPU-heavy transform in onLoad should be async, and genuinely CPU-bound work (compiling a large amount of one language to another) should either be cached aggressively across rebuilds or moved to a worker thread so it does not block the event loop that esbuild is driving.
The right caching structure is a Map keyed by args.path (plus args.suffix if you honor queries), populated on first load and invalidated only when a watched input changes. Because context() already avoids re-running onLoad for unchanged modules, this in-plugin cache mainly helps across separate build() invocations in the same process — a test runner that builds many entry points, or a dev server that rebuilds fresh rather than via a persisted context. Do not cache across process boundaries unless you also key on plugin version and configuration, or a stale cache from an old plugin build will serve wrong bytes.
Compatibility matrix
| esbuild | Plugin API | onStart/onEnd | onDispose | Notes |
|---|---|---|---|---|
| 0.8–0.13 | onResolve/onLoad | no | no | Original two-hook API. |
| 0.14–0.16 | + onStart/onEnd | yes | no | Lifecycle hooks for setup/teardown per build. |
| 0.17+ | + onDispose, context() | yes | yes | onDispose fires when a context() is disposed. |
| 0.25.x | stable | yes | yes | Current baseline; no plugin-API breaks vs 0.17. |
When not to write a plugin
A plugin is the wrong tool more often than plugin-heavy configs suggest. If all you need is one-to-one package aliasing, use the built-in alias option; it runs in Go and skips the callback machinery entirely. If you need to replace an identifier with a constant, use define, which is a compile-time substitution with no module overhead. If you need to drop imports of a file type esbuild already understands, add it to the loader map rather than intercepting the load. If you need to inject globals like process.env.NODE_ENV, define again is both faster and less surprising than a virtual module. Reach for a plugin only when the behavior is genuinely conditional — depending on the importer, the namespace, the build mode, or on state computed at build time — or when there is no file behind the specifier at all. Every plugin you add is another callback esbuild consults per matching import and another surface where a broad filter or an impure function can regress the build; the built-in options carry none of that risk because they are resolved inside the Go core.
There is also a maintenance argument. A plugin is code you own, version, and debug across esbuild upgrades; a built-in option is a stable contract esbuild maintains for you. The plugin API has been additive since 0.8, so plugins rarely break outright, but a plugin that reaches into build.initialOptions or assumes internal resolution behavior is far more fragile than one that stays within the documented onResolve/onLoad contract. When you must write a plugin, keep it to the minimum surface that does the job.
CI integration and reproducibility
In CI the determinism rules stop being advisory. A plugin that captures Date.now() or a random value produces a bundle whose content hash changes on every run, which breaks any pipeline that compares artifacts across builds, uses content-addressed caching, or expects a rebuild of an unchanged commit to be a no-op. Set SOURCE_DATE_EPOCH in the CI environment and have time-aware plugins honor it, as the build-info plugin above does, so the same commit always produces the same bytes. Pin the esbuild version in package.json with an exact version rather than a caret range, because a patch bump can change loader defaults or resolution edge cases that your plugin’s filters implicitly depend on.
Fail the build loudly rather than silently when a plugin’s assumptions are violated. An onLoad that reads a required config file should throw (or return an errors entry) if the file is missing, not fall through to undefined and let a confusing downstream Could not resolve appear instead. Returning structured errors and warnings from a callback surfaces them through esbuild’s normal reporting, so they appear in the CI log with a location and are counted toward the build’s exit status. Treat a missing required input as a build failure, and reserve undefined fall-through for the case where another handler legitimately might own the specifier.
Comparison with Rollup and Vite plugins
Rollup and Vite plugins run in the same JavaScript runtime as the bundler, so they do not have esbuild’s Go-to-Node boundary and therefore no filter gate — a Rollup resolveId or load hook is called for every module and is expected to return null for the ones it ignores. That is more ergonomic but shifts the performance responsibility onto you: an expensive check inside a Rollup hook runs for every module because there is no cheap pre-filter in a faster language rejecting non-matches first. esbuild’s filter is the price of its speed, and it is why porting a Rollup plugin to esbuild is mostly about deriving a good anchored regex from whatever the Rollup hook was checking imperatively.
The hook vocabularies map closely: Rollup’s resolveId corresponds to onResolve, load to onLoad, and buildStart/buildEnd to onStart/onEnd. What esbuild deliberately lacks is a transform hook — Rollup and Vite let many plugins each transform the same module’s code in sequence, whereas esbuild expects exactly one onLoad to produce a module’s contents and then parses them with a loader. If you need chained transforms in esbuild you compose them inside a single onLoad yourself. Vite adds its own concerns on top of Rollup (dev-server middleware, transformIndexHtml, SSR flags) that have no esbuild equivalent because esbuild is a bundler, not a dev server; Vite in fact uses esbuild internally for dependency pre-bundling and for its TypeScript-to-JavaScript transform, and those internal uses are subject to exactly the plugin mechanics described here.
Related
- esbuild & Turbopack Workflows — the parent overview of esbuild’s engine and where plugins fit.
- esbuild API and CLI for Rapid Builds — the
build/contextAPI a plugin plugs into. - Writing an esbuild plugin for inline SVG imports — a concrete
onLoadplugin end to end. - Custom Loaders and Asset Handling — how loaders and plugins divide responsibility for assets.