Salta al contenuto principale

02 - Migrazione da Node.js a Bun: gotchas

Sette differenze fra Node e Bun verificate con lo stesso codice: lockfile, script di installazione, file .env, moduli, ordine degli eventi e test.

Cosa ne pensi di questo articolo?

02 - Migrazione da Node.js a Bun: gotchas
Bun Runtime 2027Foto:Brett SaylesPexels

In Breve

Sette differenze fra Node e Bun verificate con lo stesso codice: lockfile, script di installazione, file .env, moduli, ordine degli eventi e test.

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 seguito da bun install nella stessa cartella
$ 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.

la stessa dipendenza con npm e con Bun, senza e con trustedDependencies
$ 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.

SALUTO letta da process.env con tre invocazioni
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.

percorsi.mjs
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".

sconto.ts: prezzi in centesimi, per evitare errori con i decimali
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:

sconto.test.ts, eseguito con «bun test»
// 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:

esito di «node --test sconto.test.ts»
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.

ordine.mjs
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:

rileva.mjs
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.

  1. Installa con bun install lasciando intatto il resto e confronta le versioni risolte con il lockfile di partenza.
  2. Esegui bun pm untrusted e decidi pacchetto per pacchetto che cosa mettere in trustedDependencies.
  3. Lancia la suite di test con bun test (o con il tuo runner, ma eseguito da Bun) e guarda cosa cambia.
  4. Avvia l'applicazione con bun run in un ambiente non critico e controlla variabili d'ambiente e percorsi.
  5. 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

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti