esbuild: Go ile Yazılan Hız, API, Plugin'ler ve Sınırları
esbuild hızlıdır çünkü Go ile native koda derlenir, işi çekirdeklere paralel dağıtır ve AST üzerinde yalnız üç geçiş yapar; lodash-es'in tamamını webpack'ten yaklaşık 22 kat hızlı ama %9 daha büyük bundle'ladı. Bookstore widget'ında CLI'ı, JS API'sini, metafile'ı ve onResolve/onEnd plugin'lerini kullanıyor; type check, HMR, HTML ve code splitting'deki sınırlarını ve Angular CLI'daki yerini görüyoruz.
Sürüm notu: Bu yazıdaki örnekler esbuild 0.28, TypeScript 7.0 ve Node.js 24 ile denenmiştir; karşılaştırmalarda Vite 8.3, Rolldown 1.2 ve webpack 5.112 kullanılmış, tarayıcı kontrolleri Playwright 1.65'in Chromium'uyla yapılmıştır. Süre ölçümleri yazarın kendi makinesinden alındı; mutlak değerlere değil, aradaki orana bak.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) kitap arama widget'ı lodash-es'ten yalnız debounce kullanıyor; ama bundler'ların hızını karşılaştırmak için daha ağır bir iş lazım. Şu dosya lodash-es'in tamamını, yani 640 module'ü bundle'a çekiyor:
import * as _ from "lodash-es";
console.log(Object.keys(_).length);
Bu dosyayı aynı Node.js sürecinde esbuild, Rolldown ve webpack ile beşer kez bundle'layıp minify eden bir script yazdık. Bu makinede webpack'in medyanı yaklaşık 450 ms çıktı. Sence esbuild kaç ms sürdü: webpack'in yarısı mı, onda biri mi, daha da azı mı? İkinci soru: üç aracın çıktısı aynı boyutta mı, değilse en küçüğü hangisi?
Bu yazı Build Araçları serisinin üçüncü yazısı. webpack'ten sonra tam tersi bir felsefeyle yazılmış araca bakıyoruz: esbuild. Neden bu kadar hızlı olduğunu, CLI'ını ve JS API'sini, plugin'lerini ve özellikle nerede yetmediğini aynı widget üzerinde göreceğiz.
esbuild Neden Bu Kadar Hızlı?
Ölçümün sonucu:
npm run bench
esbuild median 20 ms, output 95.6 KiB
rolldown median 17 ms, output 88.3 KiB
webpack median 447 ms, output 87.8 KiB
esbuild bu işte webpack'ten yaklaşık 22 kat hızlı. Ama tablonun ikinci sütunu da önemli: esbuild'in çıktısı diğer ikisinden yaklaşık %9 büyük. esbuild hız için bazı optimizasyonlardan bilerek vazgeçiyor; webpack'in minifier'ı Terser ve Rolldown'ınki Oxc, kodu daha fazla geçişle ve daha agresif sıkıştırıyor. Rolldown'ın bu işte esbuild'le aynı hızda olması da 2026'nın önemli bir gerçeği: Rust ile yazılan yeni nesil araçlar esbuild'in hız avantajını büyük ölçüde kapattı.
esbuild'in kendi dokümanı hızın kaynağını birkaç maddeyle açıklıyor. Go ile yazıldığı için native koda derleniyor ve her çalıştırmada bir JavaScript motorunun warm-up'ını beklemiyor. Parse ve kod üretimi işlemci çekirdeklerine paralel dağıtılıyor; Go'nun thread'ler arasında belleği paylaşabilmesi, JavaScript worker'larındaki gibi veri kopyalama maliyetini ortadan kaldırıyor. Kod, AST (Abstract Syntax Tree; kaynak kodun ağaç biçimindeki temsili) üzerinde yalnız üç kez dolaşıyor; transform, minify ve source map gibi işler ayrı geçişler yerine aynı geçişlerde birleştiriliyor ve veri işlemci cache'inde kalıyor. Son olarak araç baştan yazılmış: TypeScript parser'ı dahil hiçbir üçüncü parti kütüphaneye bağlı değil, bu yüzden veri yapıları baştan sona performansa göre tasarlanmış.
Küçük projede tablo farklı görünür. Widget'ın kendisini build ederken komut satırından ölçtüğümüz medyanlar şöyle: esbuild CLI 20 ms, aynı build'i Node.js script'iyle çalıştırınca 61 ms, vite build 99 ms, webpack 294 ms. Script'le CLI arasındaki 40 ms'lik fark tamamen Node.js'in açılış süresi. Küçük bir projede araçların asıl işi milisaniyeler sürüyor ve süreyi açılış maliyeti belirliyor; fark proje büyüdükçe açılıyor.
Tek Komutla Bundle: esbuild CLI
esbuild'in en sade kullanımı tek bir komut:
npx esbuild src/main.ts --bundle --minify --outdir=dist-cli
dist-cli/main.js 4.0kb
dist-cli/main.css 253b
⚡ Done in 31ms
Config dosyası yok, loader yok. main.ts'in import ettiği CSS, JS'in yanına ayrı bir dosya olarak çıktı. --bundle bayrağı kritik: esbuild varsayılan olarak bundle yapmaz, yalnız verilen dosyayı çevirir; bayrak yoksa import'lar olduğu gibi kalır.
Varsayılanlara dikkat. Çıktının formatı iife (tanımlandığı anda çalışan bir fonksiyona sarılmış klasik script), target ise esnext, yani hiçbir syntax indirilmiyor. CSS'e bakınca bunu görüyoruz:
#book-finder{max-width:32rem;font-family:system-ui,sans-serif;.finder-input{width:100%;padding:.5rem .75rem;border:1px solid #94a3b8;border-radius:.375rem}.finder-results{list-style:none;padding:0;li{padding:.375rem 0;border-bottom:1px solid #e2e8f0}}}
Nesting olduğu gibi kaldı. Vite 8 aynı CSS'i varsayılan Baseline target'ıyla düzleştirmişti. esbuild'de Baseline ya da Browserslist kavramı yok; target'ı tarayıcı sürümleriyle açıkça vermen gerekiyor: --target=chrome111,edge111,firefox114,safari16.4. Bu dört değer, Vite 8'in baseline-widely-available için kullandığı sürümlerle aynı; aşağıdaki build script'inde de bunları kullanıyoruz.
JS API ile Build Script'i
Gerçek bir projede CLI bayrakları çoğaldıkça bir build script'ine geçilir. esbuild'in config dosyası diye bir şeyi yok; config, JavaScript API'sine verilen bir object:
import * as esbuild from "esbuild";
import { writeFile } from "node:fs/promises";
import { htmlPlugin } from "./esbuild/html-plugin.mjs";
import { noCommonJsLodash } from "./esbuild/no-commonjs-lodash.mjs";
const serve = process.argv.includes("--serve");
const options = {
entryPoints: ["src/main.ts"],
bundle: true,
format: "esm",
splitting: true,
target: ["chrome111", "edge111", "firefox114", "safari16.4"],
minify: !serve,
sourcemap: serve,
outdir: "dist-esbuild",
entryNames: "[name]-[hash]",
metafile: true,
logLevel: "info",
plugins: [noCommonJsLodash(), htmlPlugin({ template: "index.html", entry: "src/main.ts", liveReload: serve })],
};
if (serve) {
const ctx = await esbuild.context(options);
await ctx.watch();
await ctx.serve({ servedir: "dist-esbuild", port: 8000 });
} else {
const result = await esbuild.build(options).catch(() => process.exit(1));
await writeFile("dist-esbuild/meta.json", JSON.stringify(result.metafile));
console.log(await esbuild.analyzeMetafile(result.metafile));
}
metafile: true build'in sonunda bir JSON döndürüyor: hangi girdi dosyası hangi çıktıya kaç bayt kattı, kim kimi import etti. analyzeMetafile bunu okunur bir tabloya çeviriyor:
npm run build:esbuild
dist-esbuild/main-XBSKC5GL.js 4.0kb
dist-esbuild/main-ZY63JJGU.css 306b
⚡ Done in 37ms
dist-esbuild/main-XBSKC5GL.js 4.0kb 100.0%
├ node_modules/lodash-es/debounce.js 940b 23.1%
├ src/finder.ts 487b 12.0%
├ src/books.ts 379b 9.3%
├ node_modules/lodash-es/toNumber.js 345b 8.5%
...
├ node_modules/lodash-es/now.js 44b 1.1%
└ node_modules/lodash-es/_Symbol.js 19b 0.5%
dist-esbuild/main-ZY63JJGU.css 306b 100.0%
└ src/styles.css 305b 99.7%
Bundle'ın neyden oluştuğunu görmek için ayrı bir analyzer kurmana gerek kalmıyor; aynı meta.json dosyası esbuild'in web sitesindeki görsel analyzer'a da yüklenebiliyor. Bundle analizini serinin code splitting ve tree shaking yazısında araçlar arası karşılaştırmalı göreceğiz. Bu build'de target verdiğimiz için CSS düzleştirildi ve 253 bayttan 306 bayta çıktı; [hash] sayesinde dosya adları da içerikten üretildi.
Plugin API: onResolve, onLoad ve onEnd
esbuild'in plugin'i, setup(build) fonksiyonu olan bir object. Bu fonksiyonun içinde üç tür callback kaydedersin: onResolve bir import yolunun hangi dosyaya çözüleceğine karar verir, onLoad bir dosyanın içeriğini ve hangi loader'la işleneceğini sağlar, onStart ve onEnd build'in başında ve sonunda çalışır. Her callback'in bir filter'ı var ve bu bir Go regular expression'ı; esbuild bir yolu JavaScript'e hiç geçmeden Go tarafında eler. Filtreyi geniş tutmak (/.*/) her import'ta JavaScript'e geçiş demek ve hızın büyük kısmını yer.
İlk plugin'imiz bir koruma. lodash'ın CommonJS sürümü (lodash) tree shaking'den geçemiyor; biri yanlışlıkla onu import ederse build dursun:
// "lodash-es" yerine CommonJS "lodash" import edilirse build'i durdurur
export function noCommonJsLodash() {
return {
name: "no-commonjs-lodash",
setup(build) {
build.onResolve({ filter: /^lodash(\/.*)?$/ }, args => ({
errors: [{ text: `"${args.path}" is CommonJS and cannot be tree-shaken; import from "lodash-es" instead` }],
}));
},
};
}
finder.ts'teki import'u from "lodash" yapınca:
✘ [ERROR] "lodash" is CommonJS and cannot be tree-shaken; import from "lodash-es" instead [plugin no-commonjs-lodash]
src/finder.ts:1:25:
1 │ import { debounce } from "lodash";
╵ ~~~~~~~~
1 error
Hata, satır ve sütun bilgisiyle ve plugin'in adıyla raporlanıyor. Kontrol çözümlemeden önce yapıldığı için lodash paketinin kurulu olmasına bile gerek yok.
İkinci plugin, esbuild'in hiç yapmadığı bir işi üstleniyor: HTML. esbuild HTML dosyalarını işlemez; hash'li dosya adlarını sayfaya kendin yazman gerekir. onEnd callback'i build bitince metafile'ı okuyup şablondaki script etiketini gerçek dosya adlarıyla değiştiriyor:
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
// esbuild HTML işlemez: build bitince hash'li dosya adlarını şablona yazar
export function htmlPlugin({ template, entry, liveReload = false }) {
return {
name: "html",
setup(build) {
build.onEnd(async result => {
if (result.errors.length > 0) {
return;
}
const outdir = build.initialOptions.outdir;
const [file, output] = Object.entries(result.metafile.outputs)
.find(([, output]) => output.entryPoint === entry);
const href = name => "./" + path.relative(outdir, name).replaceAll("\\", "/");
const css = output.cssBundle ? ` <link rel="stylesheet" href="${href(output.cssBundle)}">\n` : "";
const scripts = [
`<script type="module" src="${href(file)}"></script>`,
liveReload ? `<script type="module">new EventSource("/esbuild").addEventListener("change", () => location.reload());</script>` : "",
].filter(Boolean).join("\n ");
const html = (await readFile(template, "utf8"))
.replace("</head>", `${css}</head>`)
.replace(/<script type="module" src="[^"]+"><\/script>/, scripts);
await writeFile(path.join(outdir, "index.html"), html);
});
},
};
}
Metafile'daki cssBundle alanı, bir JS entry'sinin import ettiği CSS'ten üretilen dosyayı gösteriyor. Çıkan sayfayı Chromium'da açtık: 3 istekte (HTML, CSS, JS) toplam 4.6 KB geldi ve "austen" araması Emma ile Pride and Prejudice'i listeledi. onLoad'ı bu iki plugin'de kullanmadık ama mantığı aynı: filtreye uyan bir dosya yüklenirken içeriği sen döndürürsün, örneğin bir YAML (config dosyalarında kullanılan, girintiye dayalı metin biçimi) dosyasını okuyup loader: "json" ile JSON olarak verirsin ya da sanal bir module üretirsin.
Type Check Yok: tsc Ayrı Bir Adım
esbuild TypeScript'i yalnızca çevirir; tipleri kontrol etmez ve dokümanı da bunu açıkça yazar, yanında tsc --noEmit çalıştırmanı önerir. Serinin ilk yazısında aynı durumu Vite'ta görmüştük: bir fiyatı string yazdığımızda build başarıyla bitmiş, hatayı yalnız tsc yakalamıştı. esbuild'de de sonuç aynı. Bu yüzden widget'ın package.json'ında typecheck ayrı bir script; CI'da build'den önce ya da paralel çalışıyor. TypeScript 7'nin Go ile yazılan derleyicisiyle bu adım widget'ta 51 ms sürüyor; type check artık esbuild'in kendisiyle aynı büyüklükte bir süre.
esbuild her dosyayı diğerlerinden bağımsız çevirdiği için, tip bilgisi gerektiren TypeScript özelliklerini destekleyemez. Dokümanda listelenen başlıcaları şunlar:
| Konu | esbuild'in davranışı |
|---|---|
| Tip kontrolü | Yok; tsc --noEmit ayrı çalıştırılmalı |
isolatedModules | Açık olmalı; export { T } ile tip re-export'u yerine export type { T } yazılmalı |
emitDecoratorMetadata | Desteklenmiyor; tip sistemi gerektiriyor |
| Decorator'lar | experimentalDecorators açıksa TypeScript'in eski davranışı, kapalıysa standart JavaScript decorator'ları |
tsconfig.json | Yalnız belirli alanlar okunuyor: target, paths, JSX (JavaScript içinde HTML benzeri syntax) ayarları, verbatimModuleSyntax, experimentalDecorators, useDefineForClassFields, extends ve birkaçı daha |
Son satır sık atlanan bir tuzak: tsconfig'teki lib, noUnusedLocals ya da moduleResolution gibi alanlar esbuild için hiçbir şey ifade etmez. emitDecoratorMetadata satırı da önemli: reflect-metadata'ya dayanan dependency injection library'leri esbuild ile çalışmaz. Vite 8 bu açığı Oxc ile kapattı ve emitDecoratorMetadata'yı yerleşik olarak destekliyor. Angular tarafında decorator'ları ise esbuild değil Angular'ın kendi derleyicisi işliyor.
Sınırlar: HMR Yok, Splitting Yalnız ESM
esbuild'in watch ve serve mode'u var, ama HMR (Hot Module Replacement; sayfayı yenilemeden yalnız değişen module'ü değiştirme) yok. FAQ'ta (sık sorulan sorular sayfası) HMR, Module Federation ve type check, esbuild'e eklenmeyecek özellikler arasında sayılıyor. Yapabildiği şey live reload: serve mode, /esbuild adresinde bir event stream açıyor ve her rebuild'de bir change event'i gönderiyor. HTML plugin'imiz serve mode'da sayfaya bu event'i dinleyip sayfayı yenileyen tek satırlık bir script ekliyor:
npm run dev:esbuild
[watch] build finished, watching for changes...
> Local: http://127.0.0.1:8000/
> Network: http://192.168.1.20:8000/
Sayfayı Chromium'da açıp finder.ts'teki placeholder metnini değiştirdik. esbuild değişikliği gördü ([watch] build started (change: "src/finder.ts")), sayfa ikinci kez yüklendi ve yeni placeholder göründü. Ama bu tam bir yeniden yükleme: arama kutusuna yazdığın metin ve sayfadaki bütün state kayboluyor. Vite'ın HMR'ı ise yalnız değişen module'ü değiştirir; bunu serinin bir sonraki yazısında göreceğiz. esbuild'in dokümanı yalnız CSS değiştiğinde sayfayı yenilemeden stil dosyasını değiştiren daha gelişmiş bir istemci kodu da gösteriyor; JavaScript için böyle bir yol yok.
İkinci sınır code splitting. Dokümanda hâlâ "çalışma devam ediyor" olarak işaretli ve yalnız ESM (ECMAScript Modules, import/export ile yazılan standart module sistemi) çıktıda çalışıyor:
npx esbuild src/main.ts --bundle --splitting --format=iife --outdir=dist-iife
✘ [ERROR] Splitting currently only works with the "esm" format
1 error
Üçüncüsü sürümleme. esbuild hâlâ 0.x'te ve FAQ'a göre breaking change'ler minor sürümde, geriye uyumlu düzeltmeler patch sürümünde geliyor. 0.28.0'ın kendisi böyle bir sürümdü: npm üzerinden indirilen platform binary'leri için bütünlük kontrolü ekledi. package.json'daki ^0.28.2 npm'de 0.29'u kapsamaz, bu yüzden güvenli; ama 0.28'den 0.29'a geçişi her zaman bir major sürüm yükseltmesi gibi ele al ve CHANGELOG'u oku.
esbuild Nerede Kullanılıyor? Angular CLI'a Köprü
Hızı ve sınırları birlikte düşününce esbuild'in en iyi olduğu yer belli oluyor: başka bir aracın içinde, hızlı bir motor olarak. Vite 7'ye kadar Vite'ın dev server tarafındaki dependency pre-bundling'i ve TypeScript dönüşümü esbuild ile yapılıyordu; Vite 8 bu işleri Rolldown ve Oxc'ye taşıdı ve esbuild'i opsiyonel bir bağımlılığa indirdi. Angular CLI'da ise esbuild merkezde duruyor. Angular'ın build sistemi dokümanı yeni sistemin bundler'ını esbuild olarak tanımlıyor ve Vite'ın yalnız dev server olarak kullanıldığını söylüyor; @angular/build 22.2.7'nin bağımlılık listesinde esbuild 0.28.2, Vite 8.3.5 ve Rolldown 1.2.16 birlikte yer alıyor. Angular'ın derleyicisi template'leri ve decorator'ları işliyor, ortaya çıkan JavaScript'i esbuild bundle'lıyor. Bu builder'ın içini, Rolldown'ın oradaki rolünü ve webpack tabanlı browser builder'ından geçişi serinin Angular CLI yazısında ayrıntılı göreceğiz.
Tek başına esbuild ise şu işlerde doğru seçim: Bookstore.Web gibi server-side render edilen bir sitede birkaç sayfaya küçük script'ler bundle'lamak (MPA, yani her sayfası sunucudan ayrı gelen çok sayfalı uygulama), Node.js için CLI aracı ya da Lambda fonksiyonu paketlemek, bir library'nin ESM çıktısını üretmek ve test runner'lar gibi başka araçların içinde TypeScript'i hızla çevirmek.
Üretimde Dikkat
esbuild'in sürümünü kilitle ve yükseltmeyi bilinçli yap. 0.x sürümlemesi minor sürümü kırıcı sayıyor; package-lock.json'ı commit'le ve CI'da npm ci kullan.
Target'ı açıkça yaz. Varsayılan esnext hiçbir syntax'ı indirmez; desteklediğin en eski tarayıcıda çalışmayan kod sessizce production'a gider. Browserslist kullanan bir projedeysen o listeyi esbuild'in anladığı biçime çeviren bir adımı build script'ine ekle ve iki listeyi tek kaynaktan üret.
Metafile'ı CI artifact'i olarak sakla. meta.json her build'de bundle'ın neyden oluştuğunu kaydediyor; iki sürüm arasında bundle neden büyüdü sorusunu bu iki dosyayı karşılaştırarak cevaplayabilirsin.
Plugin filtrelerini dar tut. filter: /.*/ ile her dosyaya bağlanan bir plugin, esbuild'in Go tarafında kalan işini JavaScript'e taşır ve build'i belirgin şekilde yavaşlatır.
Sık Düşülen Tuzaklar
Birincisi, esbuild'i her şeyi yapan bir araç sanmak. HTML'i işlemez, type check yapmaz, HMR'ı yoktur, Browserslist okumaz, CSS'te PostCSS plugin'lerini çalıştırmaz. Bunları ya plugin'lerle sen eklersin ya da esbuild'i bunları yapan bir aracın içinde (Vite 7, Angular CLI) kullanırsın.
İkincisi, library build'de bağımlılıkları bundle'a gömmek. Widget'ın finder.ts'ini bir library olarak iki şekilde paketledik:
npx esbuild src/finder.ts --bundle --format=esm --minify --outfile=dist-lib/finder.js
# dist-lib/finder.js 3.6kb
npx esbuild src/finder.ts --bundle --format=esm --minify --packages=external --outfile=dist-lib/finder.js
# dist-lib/finder.js 807b
İlkinde lodash'ın debounce kodu library'nin içine girdi. Bu library'yi kullanan bir uygulama da lodash-es'i kullanıyorsa, aynı kod bundle'da iki kez bulunur; bağımlılık bir framework ise (React, Angular) iki kopya çalışma zamanında doğrudan hataya yol açar. --packages=external bütün paket import'larını olduğu gibi bırakır: çıktı import{debounce as u}from"lodash-es" ile başlıyor. Library'lerde peerDependencies ve dependencies'teki paketler external olmalı; yalnız tek tek seçmek istiyorsan --external:lodash-es kullan.
Üçüncüsü, --bundle'ı unutmak. Bayrak olmadan esbuild yalnız verilen dosyayı çevirir ve import'ları olduğu gibi bırakır; çıktı tarayıcıda Failed to resolve module specifier hatası verir.
Dördüncüsü, esbuild'in CSS'ini PostCSS gibi sanmak. Nesting'i ve bazı yeni syntax'ları target'a göre indirir, @import ve url()'leri bundle'lar, .module.css için CSS Modules sağlar; ama Tailwind gibi PostCSS plugin'lerine dayanan bir akışı kendi başına çalıştırmaz.
Küçük Bir Deneme
★☆☆ npx esbuild src/main.ts --bundle --outdir=dist-cli komutunu bir kez --minify ile, bir kez onsuz, bir kez de --target=es2015 ile çalıştır. Üç çıktının boyutunu ve CSS'teki farkları karşılaştır.
★★☆ noCommonJsLodash plugin'ini genelleştir: yasaklı paket adlarını ve önerilen alternatiflerini bir object'ten alsın (örneğin moment için Intl.DateTimeFormat). Plugin'i bir yasaklı paketle deneyip hata mesajını kontrol et.
★★★ dist-esbuild/meta.json'ı okuyan küçük bir Node.js script'i yaz: çıktıya en çok bayt katan beş girdiyi listelesin ve toplam boyut belirli bir eşiği aşarsa sıfırdan farklı bir exit code ile çıksın. Bu script'i CI'da build'den sonra çalışacak şekilde package.json'a ekle.
Bir sonraki yazıda esbuild'in hızını bir dev server deneyimiyle birleştiren araca geçiyoruz: Vite. Native ESM dev server'ı, HMR'ı, dependency pre-bundling'i ve Vite 8 ile production build'in Rolldown'a geçişini, widget'ı Bookstore.Web'e bağlayarak göreceğiz.