Salta al contenuto principale

01 - Bun vs Node vs Deno: benchmark realistico 2027

Tre misure ripetibili su Bun, Node e Deno (avvio, creazione di Response, TypeScript) e i permessi: che cosa dimostrano, che cosa no, come scegliere.

Cosa ne pensi di questo articolo?

01 - Bun vs Node vs Deno: benchmark realistico 2027
Bun Runtime 2027Foto:Ruben BoekelooPexels

In Breve

Tre misure ripetibili su Bun, Node e Deno (avvio, creazione di Response, TypeScript) e i permessi: che cosa dimostrano, che cosa no, come scegliere.

Bun, Node e Deno: che cosa si può misurare davvero, e che cosa no

Se cerchi «Bun contro Node» trovi tabelle con numeri a due cifre di vantaggio e nessuna indicazione di che cosa sia stato misurato. Questo articolo fa il contrario: ogni numero che leggi è uscito da un listato che puoi rieseguire, su una macchina dichiarata, e accanto a ogni numero trovi che cosa non dimostra. La risposta in breve è che nessuno dei tre runtime vince su tutto: Bun parte prima e crea oggetti Response più in fretta, Node resta il riferimento per compatibilità e per come lo conoscono i team, Deno è l'unico dei tre che per impostazione predefinita non ti lascia toccare disco e rete senza chiedere.

Le misure sono state fatte su un portatile con CPU AMD Ryzen 5 5600H (12 thread logici) e 30 GB di RAM, con Bun 1.4.4, Node.js 24.11.0 e Deno 2.6.8, nel novembre 2026. Sono tre programmi piccoli e deliberatamente sintetici: servono a capire come ragionare, non a decidere al posto tuo. Se devi scegliere per un progetto vero, il passo finale è sempre misurare il tuo carico. Gli altri articoli della serie entrano nei dettagli: migrare da Node a Bun, SQLite e Redis integrati, un'API con Hono e il rilascio in produzione.

Che cosa c'è sotto: un motore diverso, non una versione più veloce di Node

Node e Deno eseguono JavaScript con V8, il motore di Chrome. Bun usa invece JavaScriptCore, il motore di Safari, e la sua pagina introduttiva lo dichiara insieme al linguaggio in cui è scritto, che oggi è Rust. Non è un dettaglio da curiosi: un motore diverso ottimizza in modo diverso, ha tempi di avvio diversi e a volte si comporta diversamente su casi limite. Per questo la compatibilità con Node è una promessa da verificare sul tuo codice, non un dato acquisito, e il secondo articolo della serie le dedica spazio.

Deno, con la documentazione di migrazione da Node, dichiara oggi di leggere anche package.json e i pacchetti npm, quindi la distanza di partenza tra i tre è molto più piccola di qualche anno fa. Quello che resta diverso è la filosofia di esecuzione, e la vedremo nella sezione sui permessi.

Misura 1: quanto ci mette a partire

L'avvio conta dove i processi nascono e muoiono spesso: strumenti da riga di comando, script di CI, funzioni che si accendono a ogni richiesta. Il listato lancia uno script che stampa una riga e basta, trenta volte per runtime, e riporta mediana, minimo e massimo del tempo di parete misurato da fuori con time.perf_counter. Misurare da fuori include tutto: caricamento dell'eseguibile, inizializzazione del motore, lettura dello script e chiusura. La mediana è preferibile alla media perché un singolo avvio rallentato dal sistema operativo non sposta il risultato.

avvio.py (esegui con python3, accanto a vuoto.js)
import subprocess, statistics, time, sys
B="/home/federicocalo/.cache/tmp/bun04/node_modules/.bin/bun"; D="/home/federicocalo/.deno/bin/deno"
cmd={"bun":[B,"vuoto.js"],"node":["node","vuoto.js"],"deno":[D,"run","vuoto.js"]}
N=30
for nome,c in cmd.items():
    ts=[]
    for _ in range(N):
        t=time.perf_counter(); subprocess.run(c,stdout=subprocess.DEVNULL,check=True); ts.append((time.perf_counter()-t)*1000)
    ts.sort()
    print(nome, "mediana %.0f ms"%statistics.median(ts), "min %.0f"%ts[0], "max %.0f"%ts[-1])

# Output:
# bun mediana 15 ms min 13 max 19
# node mediana 29 ms min 22 max 43
# deno mediana 40 ms min 33 max 64

I tre numeri sono mediane in millisecondi. Ripetendo la prova tre volte di seguito Bun è rimasto fra 14 e 15 ms, Node fra 24 e 32 ms e Deno fra 40 e 41 ms: l'ordine non è cambiato, ma i valori assoluti sì, per questo vanno letti come ordini di grandezza e non come costanti. Va notato che Deno è risultato il più lento di Node su questa prova, il contrario di quanto si legge spesso. Il motivo non lo so dire senza un profilo: potrebbe dipendere dal controllo dei permessi e dal modulo di avvio, ma è un'ipotesi, non una misura. Una differenza di venti millisecondi su un processo che vive ore non conta nulla; su una funzione invocata un milione di volte al giorno comincia a pesare.

Misura 2: quanto lavoro per richiesta, senza la rete

I benchmark HTTP che si trovano in giro mescolano tre cose: il server, il generatore di carico e la rete locale. Per non attribuire al runtime ciò che non è suo, qui ho misurato soltanto il livello che i tre hanno in comune, quello dello standard Web: una funzione che riceve una Request e restituisce una Response, chiamata direttamente in un ciclo. È esattamente la forma dell'handler fetch che Bun, Deno e framework come Hono si aspettano, e non apre nessuna porta.

handler.mjs (stesso file per i tre runtime)
// Misura il solo livello "Request -> Response", senza rete
const runtime = typeof Bun !== "undefined" ? `bun ${Bun.version}`
  : typeof Deno !== "undefined" ? `deno ${Deno.version.deno}`
  : `node ${process.versions.node}`;

const handler = (req) =>
  new Response(JSON.stringify({ ok: true, path: new URL(req.url).pathname }), {
    headers: { "content-type": "application/json" },
  });

const req = new Request("http://localhost/ping");
const giro = async (n) => {
  for (let i = 0; i < n; i++) {
    const res = await handler(req);
    await res.text();
  }
};

await giro(20_000); // riscaldamento, escluso dalla misura
const N = 200_000;
const t0 = performance.now();
await giro(N);
const ms = performance.now() - t0;
const rss = typeof Deno !== "undefined" ? Deno.memoryUsage().rss : process.memoryUsage().rss;
console.log(JSON.stringify({ runtime, richieste: N, ms: Math.round(ms), perSecondo: Math.round(N / (ms / 1000)), rssMB: Math.round(rss / 1048576) }));

// Output:
// {"runtime":"bun 1.4.4","richieste":200000,"ms":180,"perSecondo":1109461,"rssMB":40}
// {"runtime":"node 24.11.0","richieste":200000,"ms":1650,"perSecondo":121209,"rssMB":83}
// {"runtime":"deno 2.6.8","richieste":200000,"ms":344,"perSecondo":581803,"rssMB":66}
// {"runtime":"bun 1.4.4","richieste":200000,"ms":178,"perSecondo":1124184,"rssMB":41}
// {"runtime":"node 24.11.0","richieste":200000,"ms":1544,"perSecondo":129563,"rssMB":82}
// {"runtime":"deno 2.6.8","richieste":200000,"ms":383,"perSecondo":521587,"rssMB":67}
// {"runtime":"bun 1.4.4","richieste":200000,"ms":205,"perSecondo":976781,"rssMB":40}
// {"runtime":"node 24.11.0","richieste":200000,"ms":1536,"perSecondo":130220,"rssMB":85}
// {"runtime":"deno 2.6.8","richieste":200000,"ms":341,"perSecondo":585746,"rssMB":67}

Il file riconosce il runtime da Bun, Deno o dalle versioni di process, esegue 20.000 giri di riscaldamento che non entrano nel conto, poi 200.000 giri misurati, e per ognuno costruisce la risposta e ne legge il corpo con await res.text(): senza quella lettura si misurerebbe solo la creazione dell'oggetto, non il lavoro sul contenuto. Le nove righe sono tre ripetizioni per runtime. Su questa macchina Bun ha chiuso i 200.000 giri in circa 170 ms (circa 1,2 milioni al secondo), Deno in 320-360 ms e Node in 1.600-1.800 ms, con un consumo di memoria residente a fine prova di circa 41-43 MB per Bun, 66-70 MB per Deno e 83-85 MB per Node.

Che cosa dice e che cosa no. Dice che l'implementazione di Response e Request di Bun è molto più economica di quella di Node 24 in questo ciclo: è un fatto riproducibile. Non dice che un server Bun regga dieci volte più richieste di un server Node, perché in un'applicazione vera pesano il parsing HTTP, la rete, il database e la tua logica, che nel ciclo non esistono. Non ho misurato un server in ascolto: se vuoi quel numero devi rifarlo con un generatore di carico sul tuo servizio, e dichiarare come lo hai fatto. Un'altra cautela riguarda la memoria: è il valore a fine prova di un processo che ha appena fatto un ciclo, non il picco sotto carico reale.

Misura 3: eseguire TypeScript senza compilarlo prima

Fino a poco tempo fa «TypeScript nativo» distingueva Bun e Deno da Node. Non è più vero: la documentazione di Node riporta che il type stripping, cioè la rimozione delle annotazioni di tipo prima dell'esecuzione, è attivo per impostazione predefinita dalla versione 22.18 e non emette più il messaggio di funzionalità sperimentale. Lo verifico con un file qualsiasi.

tipi.ts
interface Utente { id: number; nome: string }
const u: Utente = { id: 1, nome: "Ada" };
console.log(`${u.nome} (${u.id})`);

// Output:
// bun:  Ada (1)
// node: Ada (1)
// deno: Ada (1)

Lo stesso file gira ovunque con il solo comando nome-runtime file.ts. La parola «stripping» però è precisa: Node cancella i tipi e basta, quindi la sintassi TypeScript che produce codice, come gli enum, non è supportata. Lo mostra il secondo listato.

enum.ts
enum Colore { Rosso, Verde }
console.log(Colore.Verde);

// Output:
// bun:  1
// deno: 1
// node: SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode

Bun e Deno stampano 1, cioè l'indice dell'enum, perché trasformano il codice invece di limitarsi a togliere i tipi. Node si ferma con ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX e il messaggio dice «strip-only mode»: per i costrutti che generano codice esiste il flag --experimental-transform-types, descritto nella stessa pagina. Se il tuo progetto usa enum o namespace, questa è la prima cosa da controllare prima di dire che «Node esegue TypeScript».

C'è poi l'equivoco più diffuso, che nessuno dei tre runtime risolve da solo: eseguire un file .ts non significa controllarne i tipi. Il listato seguente assegna una stringa a una variabile dichiarata number.

errore-di-tipo.ts
const n: number = "non sono un numero";
console.log(typeof n);

// Output:
// bun:  string
// node: string
// deno run: string
// deno check:
// TS2322 [ERROR]: Type 'string' is not assignable to type 'number'.
// const n: number = "non sono un numero";
//       ^
//     at errore-di-tipo.ts:1:7
// error: Type checking failed.

Bun, Node e Deno eseguono il file senza lamentarsi e stampano string: i tipi sono stati scartati, non verificati. Solo deno check, che applica il compilatore TypeScript vero, segnala l'errore TS2322 e fallisce. In un progetto serio il controllo dei tipi va quindi tenuto come passo separato in CI (tsc --noEmit con qualunque runtime, oppure deno check), indipendentemente da chi esegue il codice.

Permessi: l'unica differenza che cambia come progetti

Deno nasce con l'idea che un programma non debba poter leggere il tuo disco o aprire una connessione senza che tu l'abbia concesso: la sua documentazione la presenta come «secure by default». Da qualche versione anche Node ha un modello di permessi, stabile dalla 22.13/23.5, ma è opt-in: parte solo se passi --permission. Bun, nelle pagine che ho consultato, non ne ha uno. Lo script di prova legge un file con readFileSync.

sessione di prova: lo stesso leggi.mjs con i tre runtime
$ deno run leggi.mjs
error: Uncaught (in promise) NotCapable: Requires read access to "vuoto.js", run again with the --allow-read flag
$ deno run --allow-read=vuoto.js leggi.mjs
19 byte letti
$ node --permission leggi.mjs
Error: Access to this API has been restricted. Use --allow-fs-read to manage permissions.
$ node --permission --allow-fs-read="$PWD" leggi.mjs
19 byte letti
$ bun leggi.mjs
19 byte letti

Con Deno la prima esecuzione fallisce con NotCapable e dice quale permesso serve; con --allow-read=vuoto.js concedo la lettura di quel solo file e funziona. Con Node, --permission blocca l'API finché non aggiungo --allow-fs-read. Bun legge senza chiedere nulla. Il vantaggio pratico è concreto quando esegui codice che non hai scritto: uno script di uno strumento, una dipendenza sospetta. Sapere che può leggere solo una cartella e parlare solo con un host limita il danno di una dipendenza compromessa. Non è un isolamento completo (con permessi larghi il vantaggio sparisce), ma sposta il default dalla fiducia al sospetto.

Come scegliere, con i criteri nell'ordine in cui pesano

Le misure sopra hanno una morale: i vantaggi di velocità esistono e sono grandi nei casi limite (avvio, creazione di risposte), ma raramente sono il collo di bottiglia di un'applicazione che parla con un database. Per decidere conviene partire dal costo del cambiamento e non dal benchmark.

  • Compatibilità del tuo codice: se dipendi da addon nativi compilati o da strumenti legati a Node, ogni altra considerazione viene dopo.
  • Competenze del team: un runtime che il team conosce già si diagnostica più in fretta quando qualcosa va storto di notte; uno nuovo ha un costo di apprendimento.
  • Frequenza degli avvii: script, CLI e funzioni a richiesta guadagnano da un avvio più rapido; un servizio che resta acceso settimane no.
  • Postura di sicurezza: se esegui codice di terzi, il modello a permessi di Deno o di Node con --permission merita una prova.
  • Strumenti inclusi: Bun porta package manager, test runner e bundler; Deno formatter, linter e test; con Node li scegli e li assembli tu.

L'ordine non è casuale: i primi due criteri sono quasi sempre decisivi e non si misurano con un benchmark, gli ultimi tre spostano il giudizio solo a parità degli altri. In pratica: resta su Node quando il costo di una sorpresa supera il vantaggio, prova Bun su un servizio nuovo o su uno strumento isolato dove puoi tornare indietro, usa Deno quando i permessi o il controllo dei tipi integrato sono il motivo per cui lo scegli. Non esiste una scelta valida per tutti e per sempre.

Domande frequenti

Bun è più veloce di Node?

Nelle due misure di questo articolo sì, e di molto: avvio intorno a 15 ms contro 24-32 ms di mediana, e un ciclo di 200.000 costruzioni di Response che Bun chiude in circa un decimo del tempo di Node. Ma sono misure sintetiche su una macchina sola. Quanto questo si traduca in velocità del tuo servizio dipende da quanto del tempo di una richiesta sta nel runtime e quanto nel database e nella rete, e solo una prova sul tuo carico lo dice.

Posso eseguire TypeScript con Node senza compilatore?

Sì, per il TypeScript che si riduce a JavaScript più annotazioni: Node 24 toglie i tipi e esegue. Non funzionano gli enum e gli altri costrutti che generano codice, salvo usare il flag sperimentale di trasformazione. In nessun caso i tipi vengono controllati: serve comunque tsc --noEmit o deno check.

Quale runtime è il più sicuro?

Nessuno è sicuro per costruzione: Deno ha i permessi attivi per impostazione predefinita, Node li offre a richiesta, Bun nelle pagine consultate non li documenta. I permessi limitano ciò che uno script può fare, ma non sostituiscono la revisione delle dipendenze né gli aggiornamenti.

Che cosa sai fare adesso

Sai misurare l'avvio di un runtime da fuori con una mediana e non con una sola esecuzione, sai isolare il costo del livello Request/Response dalla rete, sai verificare se un file TypeScript girerà con type stripping o richiederà una trasformazione, e sai quando un permesso negato è la protezione che funziona. Soprattutto sai che cosa un numero non dimostra, che è ciò che rende un benchmark onesto.

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