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.
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.
// 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.
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 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.
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.
$ 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
--permissionmerita 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
- Bun Docs, pagina introduttiva - motore JavaScriptCore e linguaggio in cui è scritto il runtime; consultata il 2026-11-12
- Node.js, documentazione «Modules: TypeScript» - type stripping attivo dalla 22.18, flag per la trasformazione; consultata il 2026-11-12
- Node.js, documentazione «Permissions» - il modello di permessi, stabile dalla 22.13/23.5; consultata il 2026-11-12
- Deno, «Security and permissions» - esecuzione sicura per impostazione predefinita; consultata il 2026-11-12
- Deno, «Node and npm compatibility» - supporto di
package.jsone dei pacchetti npm; consultata il 2026-11-12 - Deno, «TypeScript» -
deno checkcontro la sola rimozione dei tipi; consultata il 2026-11-12

Commenti
Caricamento commenti...
Accedi per commentare