È uscito il Corso Java Completo
Torna al blog

Cos'è Node.js e come funziona

Cos'è Node.js e come funziona: event loop e I/O non bloccante, async/await, npm, moduli CommonJS ed ES Modules, e per cosa Node è la scelta sbagliata.

Edoardo Midali

Edoardo Midali

Developer · Content Creator

10 min di lettura

Sai scrivere JavaScript nel browser, e a un certo punto ti serve salvare dei dati da qualche parte, mandare un'email, leggere un file. Nel browser non puoi: non ha accesso al disco né al database. Serve qualcosa che esegua JavaScript fuori dalla pagina, sul server. Quel qualcosa è Node.js. In questo articolo trovi come funziona il suo modello a thread singolo, perché regge migliaia di connessioni e perché un calcolo pesante lo paralizza.

Cos'è

Node.js è un ambiente di esecuzione che permette di eseguire JavaScript fuori dal browser, tipicamente su un server, con accesso a file, rete e processi di sistema.

Non è un linguaggio e non è un framework. Il linguaggio resta JavaScript: quello che cambia è il contesto. Nel browser hai document e window. In Node no — al loro posto hai fs per i file, http per la rete, process per l'ambiente di sistema.

Sotto c'è V8, lo stesso motore JavaScript di Chrome, più uno strato scritto in C++ che gli dà accesso al sistema operativo.

Perché è nato

Prima di Node, un server web tradizionale gestiva ogni connessione con un thread dedicato. Mille utenti contemporanei, mille thread. Ogni thread occupa memoria, e passare da uno all'altro costa tempo alla CPU.

Il punto è che quei thread, per la maggior parte del tempo, non fanno niente: aspettano. Aspettano la risposta del database, la lettura dal disco, il pacchetto dalla rete. Migliaia di thread fermi che consumano memoria per stare in attesa.

Node ha preso l'altra strada: un thread solo, che non aspetta mai.

Il modello: un thread, event loop, I/O non bloccante

È IL concetto di Node. Se afferri questo, il resto viene da sé.

Il tuo codice JavaScript gira su un solo thread. Ma quando chiedi un'operazione di input/output — leggere un file, interrogare un database, chiamare una API — Node non si ferma ad aspettare. Delega l'operazione al sistema operativo, registra una funzione da richiamare a lavoro finito, e passa immediatamente alla prossima cosa da fare.

L'event loop è il ciclo che sta alla base: controlla continuamente se qualche operazione delegata è terminata e, se sì, esegue la funzione corrispondente.

L'analogia che funziona meglio è il cameriere. Un cameriere solo può servire venti tavoli se dopo aver preso l'ordinazione va dal tavolo successivo invece di restare in cucina ad aspettare il piatto. Non cucina più in fretta: semplicemente non sta fermo mentre altri lavorano per lui.

Ecco perché un thread solo regge migliaia di connessioni contemporanee. Un'API che riceve una richiesta, interroga il database e restituisce il risultato passa il 95% del tempo in attesa. Node usa quell'attesa per servire le altre richieste, con la memoria di un processo solo.

E qui arriva il rovescio della medaglia

Se il thread è uno solo, qualunque calcolo lungo blocca tutto. Non rallenta: blocca. Nessuna altra richiesta viene servita finché quel calcolo non finisce.

// Questa richiesta blocca il server per secondi.
// Nessun altro utente viene servito nel frattempo.
app.get("/report", (req, res) => {
  let somma = 0;
  for (let i = 0; i < 5_000_000_000; i++) somma += i;
  res.json({ somma });
});

Non è un caso limite teorico. Ridimensionare immagini, generare PDF grandi, hashare password con parametri alti, ordinare centomila record in memoria, fare JSON.parse di un file da 200 MB: tutte operazioni che tengono occupato il thread e fanno aspettare chiunque altro.

La differenza in una riga: le attese sono gratis in Node, il lavoro di CPU no.

Le vie d'uscita esistono — i worker_threads per parallelizzare, una coda di lavori con un processo separato che li smaltisce, o semplicemente delegare a un servizio esterno il compito pesante — ma sono soluzioni a un problema che Node ha per come è fatto, non funzionalità che lo rendono adatto al calcolo intensivo.

Da callback a async/await

Il codice non bloccante ha bisogno di un modo per dire "quando hai finito, fai questo". Node è passato attraverso tre stili.

All'inizio le callback, con il risultato che tutti ricordano:

fs.readFile("dati.json", "utf8", (err, dati) => {
  if (err) return gestisciErrore(err);
  elabora(dati, (err, risultato) => {
    if (err) return gestisciErrore(err);
    salva(risultato, (err) => {
      if (err) return gestisciErrore(err);
      console.log("fatto");
    });
  });
});

Poi le promesse, e infine async/await, che è lo standard di oggi:

import { readFile } from "node:fs/promises";

async function elaboraFile() {
  try {
    const dati = await readFile("dati.json", "utf8");
    const risultato = await elabora(dati);
    await salva(risultato);
    console.log("fatto");
  } catch (err) {
    gestisciErrore(err);
  }
}

Stessa logica non bloccante, leggibile dall'alto in basso. await non blocca il server: sospende quella funzione e libera il thread per altro. È la confusione più diffusa tra chi arriva da linguaggi sincroni.

Due trappole che costano ore:

// Lento: tre attese in fila, una dopo l'altra
const a = await getUtente();
const b = await getOrdini();
const c = await getPreferenze();

// Veloce: partono insieme, aspetti la più lenta
const [a, b, c] = await Promise.all([
  getUtente(),
  getOrdini(),
  getPreferenze(),
]);

E la seconda: una promessa rifiutata senza catch in una funzione async che nessuno attende non viene gestita da nessuna parte. In Node moderno questo termina il processo. Ogni await sta dentro un try/catch o dentro una funzione che qualcuno gestisce.

npm e l'ecosistema

npm è il gestore di pacchetti che arriva insieme a Node. npm install express e hai un server web funzionante in dieci righe.

Il registro di npm è il più grande archivio di librerie al mondo. È il principale motivo per cui Node ha vinto: qualunque cosa ti serva, esiste già un pacchetto.

Ed è anche il suo lato problematico. Installi un pacchetto e ne arrivano quaranta di rimbalzo, scritte da persone che non conosci, che finiscono nel tuo processo con i tuoi permessi.

Regole minime di buon senso:

  • Commetti sempre package-lock.json: fissa le versioni esatte e rende le installazioni riproducibili.
  • Usa npm ci nelle pipeline di deploy, non npm install: rispetta il lock file alla lettera.
  • Controlla prima di aggiungere una dipendenza: ultima pubblicazione, numero di dipendenze proprie, issue aperte.
  • npm audit ogni tanto, ma senza panico: molti avvisi riguardano strumenti di sviluppo che non finiscono in produzione.
  • Una funzione di tre righe la scrivi tu. Non tutto merita un pacchetto.

CommonJS e ES Modules: la confusione continua

Node ha due sistemi di moduli che convivono, ed è una delle prime cose contro cui sbatti.

// CommonJS — il sistema storico di Node
const express = require("express");
module.exports = { miaFunzione };

// ES Modules — lo standard JavaScript, quello del browser
import express from "express";
export { miaFunzione };
CommonJSES Modules
Sintassirequire / module.exportsimport / export
Estensione file.cjs, o .js per impostazione predefinita.mjs, o .js con "type": "module"
CaricamentoSincrono, a runtimeStatico, analizzato prima
Import dinamicirequire() ovunqueawait import()
__dirnameDisponibileNon esiste
Top-level awaitNo

Come si decide quale dei due usa Node: il campo "type" nel package.json. Con "type": "module" i file .js sono ES Modules; senza, sono CommonJS. Le estensioni .mjs e .cjs forzano l'uno o l'altro indipendentemente dal package.json.

Gli errori che vedrai e cosa significano:

  • Cannot use import statement outside a module — stai usando import in un file trattato come CommonJS. Aggiungi "type": "module" o rinomina in .mjs.
  • require is not defined in ES module scope — l'opposto.
  • __dirname is not defined — negli ES Modules non esiste. Si ricostruisce: import.meta.dirname in Node recente, oppure path.dirname(fileURLToPath(import.meta.url)).
  • ERR_REQUIRE_ESM — un pacchetto pubblicato solo come ES Module richiamato con require. Sempre più comune, perché molte librerie sono passate a ESM soltanto.

Un ES Module può importare un modulo CommonJS. Il contrario, con require, storicamente no (le versioni recenti di Node lo permettono in certi casi, ma non contarci).

Per un progetto nuovo: ES Modules, senza esitare. È lo standard del linguaggio, è quello che usi già nel browser, ed è la direzione dell'ecosistema. CommonJS lo incontri nel codice esistente e in molti tutorial ancora in circolazione — motivo per cui conviene riconoscerlo.

Per cosa Node è ottimo

  • API e servizi web. Il caso d'uso principale: tanto I/O, poca CPU. Express o Fastify e sei operativo.
  • Applicazioni in tempo reale. Chat, notifiche, collaborazione dal vivo, dashboard che si aggiornano. Le connessioni WebSocket aperte a migliaia sono esattamente ciò che il modello a event loop gestisce bene.
  • Strumenti da riga di comando. Gran parte della toolchain frontend gira su Node.
  • Backend per app JavaScript. Stesso linguaggio davanti e dietro: meno cambi di contesto, tipi e utility condivisi. È la ragione dietro stack come quelli in come scegliere lo stack.
  • Gateway e aggregatori. Un servizio che chiama cinque altri servizi e unisce le risposte è I/O puro.

Per cosa Node è la scelta sbagliata

  • Calcolo intensivo. Elaborazione di immagini e video, machine learning, simulazioni numeriche, analisi di grandi volumi di dati. Python con le sue librerie native, Go o Rust sono lo strumento giusto.
  • Applicazioni fortemente multi-thread e CPU-bound. Se il problema si risolve parallelizzando su tutti i core, Node ti sta remando contro.
  • Ambienti dove serve tipizzazione forte a livello di linguaggio. TypeScript aiuta molto, ma resta un controllo in fase di compilazione: a runtime è sempre JavaScript.

La distinzione da tenere è una sola: Node è fatto per aspettare tante cose insieme, non per calcolare in fretta.

Deno e Bun, in breve

Due alternative che girano attorno a Node e vale la pena conoscere di nome.

Deno è nato dallo stesso autore di Node come ripensamento: TypeScript direttamente, sicurezza basata su permessi espliciti (un programma non legge dal disco se non glielo concedi), libreria standard integrata.

Bun punta tutto sulla velocità: motore diverso, avvio più rapido, gestore di pacchetti molto più veloce di npm, test runner e bundler inclusi.

Entrambi sono largamente compatibili con i pacchetti npm. Entrambi hanno un ecosistema e una diffusione in produzione molto minori. Se stai imparando, impara Node — è quello che trovi nelle offerte di lavoro e nella documentazione di tutto. Bun come strumento di sviluppo locale è interessante già oggi.

In sintesi

Node.js esegue JavaScript fuori dal browser, con accesso a file, rete e sistema.

Il modello è un thread solo più event loop e I/O non bloccante: Node non aspetta mai un'operazione di input/output, la delega e passa oltre. Per questo un processo solo regge migliaia di connessioni contemporanee.

La conseguenza pratica è che un calcolo pesante blocca tutto il server. Le attese sono gratis, il lavoro di CPU no.

ES Modules per i progetti nuovi, CommonJS da saper riconoscere nel codice esistente. Il campo "type" nel package.json decide quale dei due Node applica.

Ottimo per API, tempo reale e strumenti a riga di comando; sbagliato per il calcolo intensivo.

Se JavaScript ti manca ancora sotto i piedi, parti da imparare JavaScript da zero. Per il lato frontend dello stesso ecosistema, vedi cos'è React e cos'è Next.js, che su Node ci girano sopra.