Perché migrare a Bun non è «cambiare il comando di avvio»
Bun si presenta come un sostituto di Node.js, e in molti progetti la prima prova funziona: scrivi
bun run al posto di node e l'applicazione parte. Le sorprese arrivano dopo,
quando un comportamento che davi per scontato cambia senza far rumore: una variabile d'ambiente
che compare da sola, uno script di installazione che non gira più, un ordine di esecuzione che si
inverte. Questo articolo raccoglie sette differenze che ho verificato eseguendo lo stesso codice con
Node.js 24.11.0 e Bun 1.4.4 (novembre 2026), più una sola che non ho potuto provare e che dichiaro
come tale. Per ciascuna trovi che cosa cambia, che cosa si osserva e che cosa fare.
Prima di cominciare, due prerequisiti. Se non hai ancora deciso se migrare, parti dal confronto fra Bun, Node e Deno, che spiega che cosa si può e non si può misurare. Se invece hai già deciso, tieni a portata di mano la suite di test del progetto: è lo strumento che ti dirà quali di queste differenze ti riguardano davvero.
Gotcha 1: il lockfile viene migrato, non ignorato
Se esegui bun install in un progetto che ha un package-lock.json ma non un
bun.lock, Bun non riparte da zero: legge il lockfile di npm e ne ricava il suo, mantenendo
le versioni già risolte. Lo stesso fa con yarn.lock e pnpm-lock.yaml. È la
scelta giusta per non cambiare per sbaglio le versioni in produzione, ma ha una conseguenza: da quel
momento hai due lockfile nel repository, e se il tuo CI continua a usare npm ci leggerà il
vecchio, mentre chi lavora in locale con Bun produrrà il nuovo. Il listato crea un progetto con una sola
dipendenza, la installa con npm e poi con Bun.
$ npm install
added 1 package in 234ms
$ bun install
bun install v1.4.4
migrated lockfile from package-lock.json
Saved lockfile
Checked 1 install across 2 packages (no changes)
$ ls
bun.lock node_modules package-lock.json package.json
La riga «migrated lockfile from package-lock.json» è la conferma: Bun ha trovato il lockfile di npm e
ha scritto bun.lock, un file di testo leggibile (dalla versione 1.2 è il formato
predefinito, al posto del vecchio binario bun.lockb). Il package-lock.json
resta dov'è: la documentazione dice che Bun lo preserva, e toglierlo è una decisione tua dopo la
verifica. Nei Dockerfile e nel CI conviene passare a bun install --frozen-lockfile, che
fallisce se il lockfile non è coerente con package.json invece di aggiornarlo di nascosto.
Gotcha 2: gli script di installazione delle dipendenze non partono
Il cambiamento più importante per la sicurezza è questo. Con npm, quando installi un pacchetto, gli
script postinstall che dichiara vengono eseguiti: è così che molti pacchetti scaricano o
compilano binari, ma è anche la porta d'ingresso degli attacchi alla catena di fornitura. Bun fa
l'opposto: esegue gli script solo per i pacchetti elencati in trustedDependencies nel tuo
package.json, più una lista predefinita di pacchetti noti, valida solo per quelli
scaricati da npm. Per mostrarlo ho creato una dipendenza locale con uno postinstall che
scrive un file, e l'ho installata tre volte.
$ npm install -> postinstall eseguito
$ bun install -> postinstall NON eseguito
bun pm untrusted v1.4.4 (38afee6c8)
./node_modules/dep-con-script @dep
» [postinstall]: node -e "require('fs').writeFileSync('postinstall-eseguito.txt','sì')"
These dependencies had their lifecycle scripts blocked during install.
If you trust them and wish to run their scripts, use `bun pm trust`.
$ bun install (con trustedDependencies) -> postinstall eseguito
Con npm lo script gira. Con Bun no, e bun pm untrusted elenca la dipendenza con lo script
bloccato e il comando per fidarsene. Dopo aver aggiunto "trustedDependencies": ["dep-con-script"]
a package.json, lo script gira. Il rischio pratico della migrazione è l'opposto di quello
che si teme: l'installazione riesce, l'applicazione parte, e fallisce solo quando usa il binario che
lo script avrebbe dovuto produrre (un motore di rendering, una libreria nativa). Se un pacchetto
funzionava con npm e con Bun no, controlla prima bun pm untrusted. Anche i driver per i dati cambiano: Bun porta con sé SQLite e Redis, descritti in
un altro articolo della serie, e conviene sceglierli solo dopo aver superato questi passi.
Gotcha 3: il file .env viene letto da solo
Node non carica mai un file .env in automatico: devi usare un pacchetto come dotenv oppure,
dalle versioni recenti, il flag --env-file. Bun lo fa per te, e legge anche le varianti per
ambiente. La documentazione elenca, in ordine di precedenza crescente, .env, poi
.env.production, .env.development o .env.test secondo il valore
di NODE_ENV, poi .env.local (che con NODE_ENV=test non viene
caricato) e infine le varianti .local per ambiente. Ho scritto un .env con una
riga e uno script che stampa la variabile.
node env.mjs -> SALUTO = undefined
node --env-file=.env -> SALUTO = ciao
bun env.mjs -> SALUTO = ciao
Node senza flag stampa undefined, Node con --env-file=.env e Bun stampano
ciao. La differenza è silenziosa ed è doppiamente pericolosa: nel verso Node-verso-Bun,
un file .env.local dimenticato sul server può cambiare il comportamento dell'applicazione;
nel verso Bun-verso-Node, il codice che si è sempre fidato del caricamento automatico smette di
trovare le chiavi. In un container la soluzione pulita è la stessa in entrambi i casi: passare le
variabili dall'ambiente del container e non copiare file .env nell'immagine.
Gotcha 4: CommonJS ed ES Modules convivono nello stesso file
In Node un file .mjs è ESM puro: non ha require né __dirname, e
per il percorso del file usi import.meta.dirname. Bun offre entrambi gli stili insieme,
per compatibilità. Il file di prova stampa il tipo di tre identificatori.
console.log("import.meta.dirname:", typeof import.meta.dirname);
try { console.log("__dirname:", typeof __dirname); } catch (e) { console.log("__dirname:", e.name); }
try { console.log("require:", typeof require); } catch (e) { console.log("require:", e.name); }
// Output:
// node percorsi.mjs
// import.meta.dirname: string
// __dirname: undefined
// require: undefined
// bun percorsi.mjs
// import.meta.dirname: string
// __dirname: string
// require: function
In Node __dirname e require risultano undefined; in Bun sono
definiti anche in un modulo ESM. Questo rende la migrazione verso Bun indolore, ma rende
pericolosa quella di ritorno: codice scritto su Bun che usa require in un file ESM
si rompe su Node. La regola prudente è scrivere nello stile che Node accetta (ESM con
import.meta.dirname) anche se Bun tollera di più. C'è un secondo caso, il più frequente
nei progetti TypeScript: l'import senza estensione. Ho scritto una funzione e un test che la importa
come "./sconto".
export function applicaSconto(prezzoCent: number, percentuale: number): number {
return Math.round(prezzoCent * (1 - percentuale / 100));
}
La funzione lavora in centesimi interi e arrotonda con Math.round: 20% su 19,90 euro dà
1592 centesimi, 33% su 9,99 euro dà 669. Lavorare in centesimi è la regola pratica per i soldi, perché i
decimali binari non rappresentano esattamente valori come 0,1. Il test che la usa:
// Nessun import: describe, test ed expect arrivano dai globali, come con Jest
import { applicaSconto } from "./sconto";
describe("applicaSconto", () => {
test("20% su 19,90 euro", () => {
expect(applicaSconto(1990, 20)).toBe(1592);
});
test("arrotonda al centesimo", () => {
expect(applicaSconto(999, 33)).toBe(669);
});
test("chiama la funzione di log passata come mock", () => {
const log = jest.fn();
log("sconto applicato");
expect(log).toHaveBeenCalledWith("sconto applicato");
});
});
// Output:
// bun test v1.4.4 (38afee6c8)
// 3 pass
// 0 fail
// 3 expect() calls
// Ran 3 tests across 1 file.
Il test non importa describe, test né expect: li prende dai
globali, come fa Jest. Bun lo permette, e la sua guida alla migrazione dichiara che spesso una suite di
Jest gira senza modifiche con bun test, con compatibilità dei matcher non totale. Il test
usa anche jest.fn() e passa. Lo stesso file con node --test fallisce prima
ancora di partire, perché Node richiede l'estensione nell'import:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/percorso/sconto' imported from /percorso/sconto.test.ts
Il messaggio è ERR_MODULE_NOT_FOUND su ./sconto: Node vuole
./sconto.ts. Anche qui, il codice che Bun accetta e Node no è un debito se prevedi di poter
tornare indietro, o se condividi i moduli con strumenti che usano Node.
Gotcha 5: l'ordine di esecuzione di timer e microtask
È il gotcha più sottile, perché non produce errori ma cambia l'ordine delle stampe. Il listato
programma cinque cose e ne stampa una sesta in modo sincrono: un setTimeout a zero, un
setImmediate, un process.nextTick, una promessa e un
queueMicrotask.
setTimeout(() => console.log("setTimeout 0"), 0);
setImmediate(() => console.log("setImmediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise.then"));
queueMicrotask(() => console.log("queueMicrotask"));
console.log("sincrono");
// Output:
// node ordine.mjs
// sincrono
// promise.then
// queueMicrotask
// nextTick
// setTimeout 0
// setImmediate
//
// bun ordine.mjs
// sincrono
// nextTick
// promise.then
// queueMicrotask
// setImmediate
// setTimeout 0
L'ordine è diverso. Node, in un modulo ES, esegue prima le promesse e il microtask e solo dopo il
nextTick; Bun esegue nextTick per primo. Ho provato anche la versione
CommonJS (.cjs): lì Node mette nextTick per primo, come Bun, quindi nello
stesso runtime l'ordine dipende dal tipo di modulo. Poi c'è la coppia timer/immediate: la guida
ufficiale di Node dice che nel modulo principale l'ordine fra setTimeout(..., 0) e
setImmediate è non deterministico. Nelle venti esecuzioni di ciascun runtime che ho
fatto non è mai cambiato (in Node il timer prima, in Bun l'immediate prima), ma non è una garanzia.
Se un test dipende da questi ordini, in realtà dipende da un dettaglio d'implementazione: riscrivilo
aspettando esplicitamente l'evento che ti interessa.
Gotcha 6: gli addon nativi
Qui non ho una prova eseguita, e preferisco dirlo. I moduli nativi compilati contro Node-API (i file
.node) funzionano in Bun secondo la documentazione, che dichiara di averla implementata da
zero e di far funzionare «la maggior parte» delle estensioni esistenti, caricabili con
require() o process.dlopen. I moduli costruiti con le vecchie interfacce
dirette al motore V8 non possono funzionare, perché Bun non usa V8. L'unico modo serio di saperlo per
il tuo progetto è l'elenco delle dipendenze con un passo di compilazione (cerca
node-gyp o prebuild in node_modules) provato su Bun prima di
impegnarti.
Gotcha 7: rilevare il runtime senza indovinare
Quando il codice deve comportarsi in modo diverso, il modo documentato per riconoscere Bun è la
presenza del globale Bun oppure la versione in process.versions.bun. La guida ufficiale propone le due forme e avverte che in TypeScript il controllo del globale è un errore di tipo finché non installi @types/bun. Evita di
riconoscerlo dal nome del file eseguibile o dalle variabili d'ambiente. Una funzione piccola basta:
function runtime() {
if (typeof Bun !== "undefined") return `bun ${process.versions.bun}`;
return `node ${process.versions.node}`;
}
console.log(runtime());
// Output:
// node: node 24.11.0
// bun: bun 1.4.4
Il controllo usa typeof Bun, che non lancia errore se il globale non esiste, e
process.versions.node come fallback. Usalo per scegliere implementazioni diverse in pochi
punti isolati, non come scusa per riempire il codice di rami condizionali: ogni ramo è codice che
testi due volte.
Un percorso di migrazione in ordine
L'ordine dei passi conta, perché ogni passo isola una classe di problemi. Se li fai insieme, un fallimento non ti dice a quale appartiene.
- Installa con
bun installlasciando intatto il resto e confronta le versioni risolte con il lockfile di partenza. - Esegui
bun pm untrustede decidi pacchetto per pacchetto che cosa mettere intrustedDependencies. - Lancia la suite di test con
bun test(o con il tuo runner, ma eseguito da Bun) e guarda cosa cambia. - Avvia l'applicazione con
bun runin un ambiente non critico e controlla variabili d'ambiente e percorsi. - Solo alla fine cambia l'immagine del container o il comando di avvio in produzione, mantenendo la possibilità di tornare a Node.
Se cambi l'ordine il senso si perde: portare Bun in produzione prima di avere provato gli script bloccati significa scoprirlo con l'applicazione in esercizio, mentre eseguirlo all'ultimo permette di tornare indietro con una modifica di una riga. Per la parte di produzione vedi l'ultimo articolo della serie, Bun in produzione con Docker e Kubernetes.
Domande frequenti
Devo riscrivere i test se uso Jest?
Spesso no. La guida ufficiale dichiara che in molti casi la suite gira senza modifiche con
bun test, ma la compatibilità dei matcher non è totale e molte opzioni di configurazione
di Jest non hanno un equivalente diretto. Il modo di saperlo è eseguire la tua suite e leggere i
fallimenti.
Bun usa lo stesso motore di Node?
No: Bun usa JavaScriptCore, Node usa V8. Per questo l'ordine di alcune operazioni asincrone e alcuni comportamenti ai limiti possono differire, come mostrato nel gotcha 5. Una parte dell'API è però reimplementata per essere compatibile, e il progetto pubblica una pagina di compatibilità con Node.
Posso tornare a Node dopo la migrazione?
Sì, se hai scritto nello stile che Node accetta (estensioni negli import, niente require
nei moduli ESM) e hai tenuto il lockfile di npm accanto a quello di Bun. È il motivo per cui conviene
migrare a piccoli passi e tenere il percorso di ritorno aperto finché non sei sicuro.
Che cosa sai fare adesso
Sai prevedere le sette differenze che più spesso fanno inciampare in una migrazione, e sai
verificarle sul tuo progetto con comandi precisi: bun pm untrusted per gli script
bloccati, un .env di prova per il caricamento automatico, un test con import senza
estensione per capire se stai scrivendo codice portabile. Soprattutto sai che il segnale di una
migrazione riuscita non è «parte», ma «si comporta come prima nei casi che non avevi pensato».
Fonti
- Bun Docs, «Lockfile» - formato
bun.locke migrazione automatica da npm, Yarn e pnpm; consultata il 2026-11-12 - Bun Docs, «Lifecycle scripts» -
trustedDependenciese script bloccati per impostazione predefinita; consultata il 2026-11-12 - Bun Docs, «Environment Variables» - elenco e precedenza dei file
.envletti in automatico; consultata il 2026-11-12 - Bun Guides, «Migrate from Jest to Bun's test runner» - compatibilità con Jest e limiti; consultata il 2026-11-12
- Bun Docs, «Node-API» - supporto dei moduli nativi
.node; consultata il 2026-11-12 - Bun Guides, «Detect when code is executed with Bun» - rilevamento tramite
process.versions.buno il globaleBun; consultata il 2026-11-12 - Node.js, «The Node.js Event Loop» - ordine non deterministico di
setTimeoutesetImmediatenel modulo principale; consultata il 2026-11-12

Commenti
Caricamento commenti...
Accedi per commentare