Salta al contenuto principale

03 - Bun nativo: SQLite + Redis client built-in

SQLite e Redis integrati in Bun, provati con codice eseguito: transazioni, cache-aside con scadenza, pub/sub e il costo reale del commit su disco.

Cosa ne pensi di questo articolo?

03 - Bun nativo: SQLite + Redis client built-in
Bun Runtime 2027Foto:Ruben BoekelooPexels

In Breve

SQLite e Redis integrati in Bun, provati con codice eseguito: transazioni, cache-aside con scadenza, pub/sub e il costo reale del commit su disco.

Un database e una cache senza installare un driver

Bun include nel runtime un driver per SQLite, un client per Redis e un client SQL unificato per PostgreSQL, MySQL e SQLite. Per un servizio piccolo significa meno dipendenze da installare e aggiornare. Il rischio opposto è fidarsi di esempi che non girano: con questi moduli le API sono cambiate negli ultimi anni, e molti tutorial importano funzioni che non esistono. Qui ogni listato è stato eseguito con Bun 1.4.4 su Linux (novembre 2026), e il client Redis ha parlato con un server Redis 8.0.5 locale, usando solo chiavi con un prefisso di prova poi cancellate.

L'articolo segue il percorso di una richiesta: leggere da SQLite con bun:sqlite, capire quanto costa scrivere (la parte che sorprende di più), mettere davanti una cache Redis con il pattern «cache-aside», e infine usare il pub/sub. Se non conosci ancora il runtime, parti dal confronto fra Bun, Node e Deno; il risultato di questa lezione serve poi nell'articolo su Hono e Bun.

bun:sqlite: il driver sincrono

SQLite non è un server: è una libreria che scrive su un file (o in memoria) dentro il tuo processo. Per questo l'API di bun:sqlite è sincrona: non c'è rete da aspettare, e un await in più non farebbe che aggiungere costo. Il listato apre un database in memoria con strict: true, crea una tabella, inserisce due righe con parametri con nome, legge, e infine prova una transazione che fallisce a metà.

sqlite-base.ts
import { Database } from "bun:sqlite";

const db = new Database(":memory:", { strict: true });
db.run(`CREATE TABLE utenti (
  id    INTEGER PRIMARY KEY,
  nome  TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE
)`);

const inserisci = db.query("INSERT INTO utenti (nome, email) VALUES ($nome, $email)");
inserisci.run({ nome: "Ada", email: "ada@example.com" });
inserisci.run({ nome: "Linus", email: "linus@example.com" });

const perId = db.query("SELECT id, nome FROM utenti WHERE id = ?");
console.log(JSON.stringify(perId.get(2)));
console.log(JSON.stringify(db.query("SELECT count(*) AS n FROM utenti").get()));

// Un errore dentro la transazione la annulla per intero
const duePersone = db.transaction((righe: { nome: string; email: string }[]) => {
  for (const r of righe) inserisci.run(r);
});
try {
  duePersone([
    { nome: "Grace", email: "grace@example.com" },
    { nome: "Ada bis", email: "ada@example.com" }, // email duplicata
  ]);
} catch (e) {
  console.log("errore:", (e as Error).message);
}
console.log("dopo il rollback:", JSON.stringify(db.query("SELECT count(*) AS n FROM utenti").get()));

// strict: true segnala un parametro scritto male, senza strict viene legato a NULL
try {
  db.query("SELECT $nome AS nome").get({ nom: "refuso" });
} catch (e) {
  console.log("strict:", (e as Error).message);
}

// Output:
// {"id":2,"nome":"Linus"}
// {"n":2}
// errore: UNIQUE constraint failed: utenti.email
// dopo il rollback: {"n":2}
// strict: Missing parameter "nome"

db.query(...) compila l'istruzione una volta sola e la conserva in una cache interna (per impostazione predefinita le 20 più recenti, dice la documentazione): richiamare lo stesso testo SQL è economico, e i parametri vengono legati di nuovo a ogni esecuzione. Con db.prepare(...) ottieni invece un'istruzione fresca, non in cache, utile per SQL generato dinamicamente. .get() restituisce la prima riga, .all() tutte, .run() esegue senza leggere risultati.

Il ramo try mostra la proprietà che giustifica le transazioni: la prima persona (Grace) viene inserita, la seconda ha la stessa email di Ada e viola il vincolo UNIQUE, e il conteggio dopo l'errore resta 2, quello di prima. db.transaction ha annullato anche l'inserimento riuscito. L'ultima riga mostra l'effetto di strict: true: un parametro scritto male (nom al posto di nome) produce un errore, mentre senza quell'opzione la documentazione dice che Bun lega silenziosamente NULL, un bug che si scopre in produzione. Lascia strict acceso.

Quanto costa scrivere: la transazione conta più del runtime

Qui c'è la misura che mi ha sorpreso. Ho inserito 10.000 righe in un database su file, in modalità WAL, in tre modi: un INSERT per volta (ogni istruzione è una transazione a sé), tutti dentro un'unica transazione, e un INSERT per volta ma con PRAGMA synchronous = NORMAL. Lo stesso file gira con Bun (bun:sqlite) e con Node 24 (node:sqlite), cambiando solo l'import del driver.

bench-sqlite.mjs (tre esecuzioni per runtime, nell'ordine Bun, Node, Bun, Node...)
// Stesso file per Bun e per Node: cambia solo l'import del driver
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const isBun = typeof Bun !== "undefined";
const { DatabaseSync } = isBun ? {} : await import("node:sqlite");
const { Database } = isBun ? await import("bun:sqlite") : {};

const cartella = mkdtempSync(join(tmpdir(), "sqlite-"));
const apri = (sync = "FULL") => {
  const file = join(cartella, `prova-${Math.random().toString(36).slice(2)}.db`);
  const db = isBun ? new Database(file) : new DatabaseSync(file);
  db.exec("PRAGMA journal_mode = WAL");
  db.exec(`PRAGMA synchronous = ${sync}`);
  db.exec("CREATE TABLE righe (id INTEGER PRIMARY KEY, valore TEXT NOT NULL)");
  return db;
};

const N = 10_000;
const runtime = isBun ? `bun ${Bun.version}` : `node ${process.versions.node}`;
const risultati = {};

// 1) un INSERT alla volta: ogni istruzione è una transazione
{
  const db = apri();
  const ins = db.prepare("INSERT INTO righe (valore) VALUES (?)");
  const t0 = performance.now();
  for (let i = 0; i < N; i++) ins.run(`valore-${i}`);
  risultati.unaAllaVolta_ms = Math.round(performance.now() - t0);
  db.close();
}
// 2) tutti gli INSERT dentro un'unica transazione
{
  const db = apri();
  const ins = db.prepare("INSERT INTO righe (valore) VALUES (?)");
  const t0 = performance.now();
  db.exec("BEGIN");
  for (let i = 0; i < N; i++) ins.run(`valore-${i}`);
  db.exec("COMMIT");
  risultati.inUnaTransazione_ms = Math.round(performance.now() - t0);
  db.close();
}
// 3) un INSERT alla volta, ma con synchronous = NORMAL (fsync solo ai checkpoint)
{
  const db = apri("NORMAL");
  const ins = db.prepare("INSERT INTO righe (valore) VALUES (?)");
  const t0 = performance.now();
  for (let i = 0; i < N; i++) ins.run(`valore-${i}`);
  risultati.unaAllaVolta_syncNormal_ms = Math.round(performance.now() - t0);
  db.close();
}
rmSync(cartella, { recursive: true, force: true });
console.log(JSON.stringify({ runtime, inserimenti: N, ...risultati }));

// Output:
// {"runtime":"bun 1.4.4","inserimenti":10000,"unaAllaVolta_ms":17597,"inUnaTransazione_ms":25,"unaAllaVolta_syncNormal_ms":339}
// {"runtime":"node 24.11.0","inserimenti":10000,"unaAllaVolta_ms":20752,"inUnaTransazione_ms":60,"unaAllaVolta_syncNormal_ms":294}
// {"runtime":"bun 1.4.4","inserimenti":10000,"unaAllaVolta_ms":22717,"inUnaTransazione_ms":23,"unaAllaVolta_syncNormal_ms":277}
// {"runtime":"node 24.11.0","inserimenti":10000,"unaAllaVolta_ms":18723,"inUnaTransazione_ms":51,"unaAllaVolta_syncNormal_ms":631}
// {"runtime":"bun 1.4.4","inserimenti":10000,"unaAllaVolta_ms":17194,"inUnaTransazione_ms":20,"unaAllaVolta_syncNormal_ms":324}
// {"runtime":"node 24.11.0","inserimenti":10000,"unaAllaVolta_ms":13312,"inUnaTransazione_ms":10,"unaAllaVolta_syncNormal_ms":207}

Le cifre sono in millisecondi e valgono per questa macchina e questo disco: cambiano da un'esecuzione all'altra, come si vede, e su un altro disco sarebbero diverse. L'ordine di grandezza però è stabile. Un INSERT alla volta richiede da 13 a 23 secondi, tutto in una transazione da 10 a 60 millisecondi, e con synchronous = NORMAL da 200 a 630 millisecondi. Fra Bun e Node la differenza è dentro il rumore delle ripetizioni; fra i tre modi di scrivere ci sono tre ordini di grandezza.

La spiegazione è che ogni transazione, a meno di impostazioni diverse, deve arrivare sul disco prima di dichiararsi completata: con synchronous = FULL, che è il valore usato nella prima prova, il WAL viene sincronizzato a ogni commit (lo dice la documentazione di SQLite). Diecimila commit sono diecimila attese del disco, da uno a due millisecondi ciascuna su questa macchina. Raggruppare le scritture in una transazione paga l'attesa una volta sola. Con synchronous = NORMAL l'attesa si sposta ai checkpoint: il compromesso, scritto nella documentazione di Bun e in quella di SQLite, è che dopo un'interruzione di corrente puoi perdere gli ultimi commit ma non corrompere il database. Per un contatore di visite va bene; per un ordine pagato no. La regola che ne discende: scegli tu la durabilità, non lasciarla al caso, e se importi dati in blocco usa una transazione.

Bun.sql: l'API asincrona unificata

Accanto a bun:sqlite esiste Bun.sql, un client con template literal taggati, progettato per PostgreSQL, MySQL e anche SQLite. Il vantaggio è scrivere lo stesso codice su database diversi; lo svantaggio è un'astrazione in più. I valori interpolati con ${} diventano parametri, non pezzi di testo, e questa è la protezione contro l'iniezione SQL.

bunsql.ts
import { SQL } from "bun";

// Stessa API asincrona per Postgres, MySQL e SQLite: qui SQLite in memoria
const sql = new SQL("sqlite://:memory:");
await sql`CREATE TABLE note (id INTEGER PRIMARY KEY, testo TEXT NOT NULL)`;
await sql`INSERT INTO note (testo) VALUES (${"prima"}), (${"seconda"})`;
const righe = await sql`SELECT id, testo FROM note WHERE id > ${0} ORDER BY id`;
console.log(JSON.stringify(righe));
await sql.close();

// Output:
// [{"id":1,"testo":"prima"},{"id":2,"testo":"seconda"}]

La stringa di connessione sqlite://:memory: sceglie il motore. Notare che le righe tornano già come oggetti, senza passaggi di mappatura. Una cosa che gli esempi in rete sbagliano spesso è importare una funzione sql e chiamarla con un oggetto di opzioni: quella forma non esiste. Per SQLite sincrono si usa Database da bun:sqlite; per il client unificato si crea un'istanza new SQL(url).

Redis: la cache davanti a SQLite

Il pattern «cache-aside» è il più semplice e il più usato: prima chiedi alla cache; se non c'è, leggi dal database, salvi il risultato in cache con una scadenza e lo restituisci. Il database resta la fonte di verità, la cache è una scorciatoia che può sparire senza danni. Il listato lo implementa con RedisClient importato da "bun", e conta quante volte si arriva davvero a SQLite.

cache-aside.ts
import { Database } from "bun:sqlite";
import { RedisClient } from "bun";

const PREFISSO = "bun04:";          // tutte le chiavi di prova iniziano così
const TTL_SECONDI = 60;

const db = new Database(":memory:");
db.run("CREATE TABLE utenti (id INTEGER PRIMARY KEY, nome TEXT NOT NULL)");
db.run("INSERT INTO utenti (id, nome) VALUES (1, 'Ada'), (2, 'Linus')");
const leggiDaDb = db.query("SELECT id, nome FROM utenti WHERE id = ?");

const cache = new RedisClient("redis://localhost:6379");
let letture = 0; // quante volte abbiamo davvero interrogato SQLite

async function utente(id: number) {
  const chiave = `${PREFISSO}utente:${id}`;
  const inCache = await cache.get(chiave);
  if (inCache !== null) return { origine: "redis", valore: JSON.parse(inCache) };

  letture++;
  const riga = leggiDaDb.get(id);
  if (riga === null) return { origine: "assente", valore: null };

  // SET con EX in un solo comando: valore e scadenza nascono insieme
  await cache.send("SET", [chiave, JSON.stringify(riga), "EX", String(TTL_SECONDI)]);
  return { origine: "sqlite", valore: riga };
}

for (const id of [1, 1, 2, 1, 99]) {
  const r = await utente(id);
  console.log(`utente ${id}: da ${r.origine}`, JSON.stringify(r.valore));
}
console.log("letture su SQLite:", letture);
console.log("TTL residuo di utente:1 =", await cache.ttl(`${PREFISSO}utente:1`), "secondi");

await cache.del(`${PREFISSO}utente:1`, `${PREFISSO}utente:2`);
cache.close();

// Output:
// utente 1: da sqlite {"id":1,"nome":"Ada"}
// utente 1: da redis {"id":1,"nome":"Ada"}
// utente 2: da sqlite {"id":2,"nome":"Linus"}
// utente 1: da redis {"id":1,"nome":"Ada"}
// utente 99: da assente null
// letture su SQLite: 3
// TTL residuo di utente:1 = 60 secondi

Cinque richieste, tre accessi a SQLite: l'utente 1 la seconda e la quarta volta arriva da Redis, l'utente 2 e la prima richiesta dell'utente 1 da SQLite, e l'utente 99, inesistente, produce null e un nuovo accesso al database a ogni richiesta, perché il codice non salva l'assenza in cache. È una scelta consapevole per tenere l'esempio corto, ma in un servizio pubblico un elenco di identificativi inesistenti può trasformarsi in un attacco al database: la contromisura è salvare in cache anche l'assenza, con una scadenza breve.

Un secondo dettaglio sta nella riga con send("SET", [...]). Prima si scrive con set e poi si imposta la scadenza con expire: se il processo muore fra le due chiamate, resta in cache una chiave che non scade mai. Il comando SET con l'opzione EX fa le due cose insieme, e il metodo send è il modo documentato da Bun per eseguire un comando Redis qualsiasi. L'ultima riga stampa ttl pari a 60 secondi (esattamente quello impostato, perché è appena stato scritto). Il valore in cache va serializzato: Redis conserva stringhe, e JSON.stringify/JSON.parse sono lo scotto.

Pub/Sub: due connessioni, perché una è prigioniera

Redis può anche inoltrare messaggi in tempo reale: chi si iscrive a un canale riceve quanto pubblicato da altri. Nel client di Bun questa funzione è marcata sperimentale dalla documentazione (è comparsa nella versione 1.2.23). Il listato usa due client perché una connessione che si iscrive entra in «modalità subscriber» e non può più eseguire comandi normali.

pubsub.ts
import { RedisClient } from "bun";

const URL = "redis://localhost:6379";
const CANALE = "bun04:notifiche";

const ascolto = new RedisClient(URL);
await ascolto.connect();

const ricevuti: string[] = [];
const dueMessaggi = new Promise<void>((fine) => {
  ascolto.subscribe(CANALE, (messaggio, canale) => {
    ricevuti.push(`${canale} <- ${messaggio}`);
    if (ricevuti.length === 2) fine();
  });
});
await Promise.resolve(); // lascia partire la sottoscrizione

// Una connessione in sottoscrizione non accetta altri comandi: il publisher è un altro client
const editore = new RedisClient(URL);
await editore.connect();
const destinatari = await editore.publish(CANALE, "ordine 1001 pagato");
await editore.publish(CANALE, "ordine 1002 spedito");
console.log("destinatari del primo publish:", destinatari);

await dueMessaggi;
console.log(ricevuti);

// Una connessione in sottoscrizione rifiuta i comandi normali
try {
  await ascolto.get("bun04:qualsiasi");
} catch (e) {
  console.log("get durante la sottoscrizione:", (e as Error).message);
}

await ascolto.unsubscribe(CANALE);
ascolto.close();
editore.close();

// Output:
// destinatari del primo publish: 1
// [ "bun04:notifiche <- ordine 1001 pagato", "bun04:notifiche <- ordine 1002 spedito" ]
// get durante la sottoscrizione: RedisClient.prototype.get cannot be called while in subscriber mode.

Il publisher vede che il primo messaggio ha avuto un destinatario, il subscriber riceve i due messaggi nell'ordine di invio, e il tentativo di fare get sulla connessione in ascolto fallisce con un messaggio che lo spiega. Per usare comandi normali serve un'altra connessione, e la documentazione offre duplicate() proprio per questo. Il limite da ricordare è che Pub/Sub non conserva nulla: se il subscriber è spento quando il messaggio parte, lo perde. Per una coda affidabile servono altri strumenti di Redis (le liste o gli stream), che qui non ho provato.

Quando Redis non risponde

Una cache deve degradare, non fermare il servizio. Ho puntato il client a una porta dove nessuno ascolta e ho chiesto una chiave, con un timeout breve e senza riconnessione automatica:

giu.ts
import { RedisClient } from "bun";

// Porta dove non ascolta nessuno: simula Redis spento
const c = new RedisClient("redis://localhost:6390", { connectionTimeout: 2000, autoReconnect: false });
try {
  await c.get("qualsiasi");
} catch (e) {
  const err = e as Error & { code?: string };
  console.log("nome:", err.name, "| codice:", err.code, "| messaggio:", err.message);
}

// Output:
// nome: RedisError | codice: ERR_REDIS_CONNECTION_CLOSED | messaggio: Connection closed

L'errore è un RedisError con codice ERR_REDIS_CONNECTION_CLOSED: il codice va catturato e trattato come «cache assente», cioè leggere dal database e andare avanti. I valori predefiniti del client, secondo la documentazione, sono questi:

  • connectionTimeout: 10.000 millisecondi.
  • autoReconnect: attivo, con maxRetries pari a 20.
  • enableOfflineQueue: attiva, i comandi emessi a connessione caduta vengono accodati.
  • enableAutoPipelining: attivo, più comandi vengono inviati in un'unica scrittura.

La coda offline è il valore più insidioso: con Redis giù, un servizio che aspetta una risposta accoda i comandi e ogni richiesta resta in attesa fino al timeout, quindi il database non viene raggiunto e la latenza sale a dieci secondi. Per una cache conviene un connectionTimeout breve e, a seconda del caso, disattivare la coda; il pipelining automatico invece è un guadagno senza costi per chi lancia più get con Promise.all.

Quando usare cosa, e quando no

SQLite in WAL con molti lettori e un solo scrittore è un'ottima scelta per un servizio su un'unica macchina: la documentazione di SQLite precisa però che tutti i processi che usano il database devono stare sullo stesso host e che WAL non funziona su un filesystem di rete. Se più istanze del servizio devono scrivere sugli stessi dati da macchine diverse, hai bisogno di un database server. Redis in cache ha il problema opposto: dà velocità condivisa fra istanze, ma ti aggiunge un componente da gestire e un modo di fallire. Una cache che non risolve un problema misurato è solo complessità: prima di aggiungerla controlla con il tempo della query che ne valga la pena.

Domande frequenti

bun:sqlite è più veloce di better-sqlite3?

La documentazione di Bun dichiara che per le letture è circa 3-6 volte più veloce di better-sqlite3, citando un benchmark su Northwind eseguito su un MacBook con processore M1. Non l'ho riprodotto, e il risultato di questo articolo dice che per le scritture il collo di bottiglia è il disco, non il driver.

Posso usare lo stesso file SQLite da più processi?

Sì, sulla stessa macchina e in modalità WAL, con tanti lettori e un solo scrittore alla volta. Se due processi provano a scrivere insieme, uno aspetta: imposta un tempo di attesa con PRAGMA busy_timeout per evitare l'errore «database is locked». Non l'ho provato qui.

Come proteggo Redis con una password?

L'URL di connessione accetta le credenziali, nella forma redis://utente:password@host:6379 mostrata dalla documentazione del client. Tieni la password in una variabile d'ambiente e non nel codice, e non esporre mai Redis su una rete pubblica.

Che cosa sai fare adesso

Sai aprire un database con bun:sqlite in modo che un parametro sbagliato sia un errore e non un NULL, sai perché raggruppare le scritture in una transazione conta più del runtime, sai mettere una cache Redis con scadenza atomica davanti a una query e farla degradare quando Redis non risponde, e sai dove il pub/sub si ferma. Il prossimo passo è mettere tutto dietro un'API: Bun con Hono; se arrivi da un progetto Node, rivedi prima le differenze da controllare nella migrazione.

Fonti

  • Bun Docs, «SQLite» - API di bun:sqlite, cache delle istruzioni, strict mode e WAL; consultata il 2026-11-12
  • Bun Docs, «Redis» - RedisClient, opzioni predefinite, pub/sub e errori; consultata il 2026-11-12
  • Bun Docs, «SQL» - client unificato con template literal e supporto a SQLite; consultata il 2026-11-12
  • SQLite, «Write-Ahead Logging» - sincronizzazione del WAL con synchronous, limiti di host e filesystem di rete; consultata il 2026-11-12
  • Redis, comando SET - opzione EX per impostare valore e scadenza in un solo comando; consultata il 2026-11-12

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