Por Qué SQLite en el Edge Tiene Sentido
La idea fundamental es esta: si tu base de datos es un archivo en la misma máquina que tu aplicación, las lecturas son una llamada a función, no un roundtrip de red. La diferencia de latencia es extraordinaria:
Postgres (misma región): ~2ms por query
Postgres (cross-region): ~50-180ms por query
SQLite (archivo local, modo WAL): ~0.02ms por query
No es un typo. Las lecturas de SQLite en modo WAL son literalmente 100x más rápidas que Postgres en la misma región porque no hay TCP handshake, no hay negociación de protocolo, no hay parsing de query por el cable, no hay serialización de resultados. Es un lookup en B-tree sobre un archivo memory-mapped.
La trampa, obviamente, es que SQLite es una base de datos single-node. No replica. No clusteriza. Escribe a un solo archivo.
Ahí es donde entran LiteFS, Turso y libSQL.
LiteFS: Replicación Transparente para SQLite
LiteFS, construido por el equipo de Fly.io, es genuinamente ingenioso. Es un filesystem FUSE que se sienta entre tu aplicación y el archivo SQLite real, interceptando todas las operaciones de escritura.
Así funciona la replicación a bajo nivel:
La aplicación escribe a SQLite
↓
FUSE intercepta la syscall de escritura
↓
LiteFS captura las páginas WAL cambiadas
↓
Las páginas se envían al nodo primary via HTTP
↓
El primary aplica las páginas y las reenvía a las réplicas
↓
Las réplicas aplican las páginas a su archivo SQLite local
La decisión de diseño clave es que LiteFS replica a nivel de página, no a nivel de SQL. No re-ejecuta sentencias SQL, envía las páginas de base de datos de 4KB realmente modificadas. Esto significa:
- La replicación es SQL-agnóstica (funciona con cualquier extensión de SQLite, funciones custom, etc.)
- El overhead es proporcional a los datos cambiados, no a la complejidad del query
- No hay necesidad de parsear o validar SQL del lado de la réplica
Configurando LiteFS en Fly.io
# fly.toml
[mounts]
source = "litefs"
destination = "/var/lib/litefs"
[processes]
app = "/usr/local/bin/litefs mount"
# litefs.yml
fuse:
dir: "/litefs"
data:
dir: "/var/lib/litefs"
proxy:
addr: ":8080"
target: "localhost:3000"
db: "myapp.db"
passthrough:
- "*.ico"
- "*.png"
exec:
- cmd: "node server.js"
lease:
type: "consul"
advertise-url: "http://${FLY_ALLOC_ID}.vm.${FLY_APP_NAME}.internal:20202"
consul:
url: "${FLY_CONSUL_URL}"
key: "litefs/${FLY_APP_NAME}"
La sección proxy es importante, LiteFS corre un reverse proxy que automáticamente reenvía las requests de escritura al nodo primary. Tu aplicación no necesita saber sobre la topología de replicación.
Write Forwarding
Cuando tu app corriendo en una réplica en Tokyo recibe un POST request que necesita escribir a la base de datos, pasa esto:
1. El request llega al proxy LiteFS en la réplica de Tokyo
2. LiteFS detecta que es una escritura (POST/PUT/DELETE o SQL INSERT/UPDATE/DELETE)
3. LiteFS reenvía el request al primary (Virginia) via header Fly-Replay
4. El primary ejecuta la escritura
5. Las páginas cambiadas replican a Tokyo (~120ms después)
6. La respuesta vuelve al cliente
La latencia de escritura es el roundtrip de red al primary más el tiempo de replicación. Para un usuario en Tokyo escribiendo a un primary en Virginia, son aproximadamente 180-250ms. Las lecturas, sin embargo, siguen pegando a la réplica local: 0.02ms.
Para un sitio de contenido donde el 98% de las requests son lecturas, este trade-off es excepcionalmente favorable.
Turso y libSQL: SQLite con Capacidades de Servidor
Turso toma un enfoque diferente. En vez de replicación transparente a nivel de filesystem, forkearon SQLite en libSQL y le agregaron features a nivel de servidor:
Adiciones de libSQL sobre SQLite:
├── Modo servidor (sqld), escucha en HTTP/WebSocket
├── Embedded replicas, archivo SQLite local que sincroniza con remoto
├── Namespaces, aislamiento de base de datos multi-tenant
├── Búsqueda vectorial, embeddings vectoriales nativos (tipo VECTOR)
├── Mejoras en ALTER TABLE, cambios de tipo de columna
└── Funciones definidas por usuario basadas en WASM
La feature de embedded replica es lo que hace a Turso interesante para deployments en el edge:
import { createClient } from "@libsql/client";
const client = createClient({
url: "file:local-replica.db", // archivo SQLite local
syncUrl: "libsql://mydb-myorg.turso.io", // primary remoto
authToken: process.env.TURSO_AUTH_TOKEN,
syncInterval: 60, // sincronizar cada 60 segundos
});
// Las lecturas pegan al archivo local, ~0.1ms
const users = await client.execute("SELECT * FROM users WHERE active = 1");
// Las escrituras van al primary remoto, ~50ms
await client.execute({
sql: "INSERT INTO users (name, email) VALUES (?, ?)",
args: ["Alice", "alice@example.com"],
});
// Forzar sync cuando se necesita datos frescos
await client.sync();
El mecanismo de sync es más sofisticado que LiteFS. Turso trackea un vector clock por namespace y solo envía el delta de frames cambiados desde el último sync. La embedded replica mantiene un WAL local que se reconcilia durante el sync.
Benchmarks Reales
Corrí estos contra un dataset del mundo real: 2 millones de registros de productos, ~800MB de base de datos, corriendo en máquinas de Fly.io (shared-cpu-1x, 256MB RAM).
Performance de Lectura
Query: SELECT * FROM products WHERE category = ? AND price < ? ORDER BY rating DESC LIMIT 20
Index: CREATE INDEX idx_products_cat_price ON products(category, price)
SQLite (local, modo WAL):
p50: 0.08ms
p99: 0.3ms
Throughput: 42,000 queries/sec (single connection)
Turso (embedded replica, sincronizado):
p50: 0.09ms
p99: 0.35ms
Throughput: 38,000 queries/sec
Postgres (misma región, pgbouncer):
p50: 1.8ms
p99: 4.2ms
Throughput: 8,500 queries/sec (50 conexiones)
Postgres (cross-region, réplica más cercana):
p50: 12ms
p99: 45ms
Throughput: 2,100 queries/sec
SQLite es 5x más rápido que Postgres en la misma región y 150x más rápido que cross-region. Los números de throughput son todavía más dramáticos porque SQLite no tiene overhead de conexión, cada query es una llamada a función directa al B-tree.
Performance de Escritura
Query: INSERT INTO orders (user_id, product_id, quantity, total) VALUES (?, ?, ?, ?)
SQLite (local, modo WAL):
p50: 0.05ms
p99: 2.1ms (spikes de checkpoint WAL)
Throughput: 18,000 inserts/sec (single writer)
LiteFS (escritura al primary, replicar):
p50: 85ms (red al primary)
p99: 210ms
Throughput: ~200 writes/sec (limitado por RTT de red)
Turso (embedded replica, escritura al primary):
p50: 52ms
p99: 130ms
Throughput: ~350 writes/sec
Postgres (misma región):
p50: 1.5ms
p99: 5ms
Throughput: 12,000 inserts/sec (escritores paralelos)
Acá es donde Postgres gana de manera decisiva. La restricción de single-writer de SQLite significa que no puede paralelizar escrituras. Y en un deployment edge, cada escritura tiene que pegar al primary por la red. Si se está haciendo más de unos cientos de escrituras por segundo, se necesita Postgres (u otra base de datos multi-writer).
Cuándo Usar Esto Realmente
Después de correr SQLite en el edge en producción por más de un año, este es mi framework de decisión:
Utilizar SQLite + LiteFS/Turso cuando:
- El ratio lectura-a-escritura es 90%+ lecturas
- El dataset entra cómodamente en una sola máquina (<100GB)
- Necesitás latencia de lectura sub-milisegundo globalmente
- Querés cero overhead operacional de base de datos
- Estás en una plataforma que lo soporta (Fly.io, Cloudflare Workers con D1, etc.)
Quedarse con Postgres cuando:
- El throughput de escritura importa (>500 writes/sec sostenidos)
- Necesitás transacciones complejas entre múltiples tablas
- El dataset es grande (>100GB) o crece rápido
- Necesitás features avanzados (JSONB, full-text con diccionarios custom, vistas materializadas)
- Teners un equipo DBA dedicado
El enfoque híbrido (lo que realmente hago para la mayoría de los proyectos):
┌─────────────────────────────┐
│ Edge (regiones Fly.io) │
│ ┌───────────────────────┐ │
│ │ SQLite (via LiteFS) │ │
│ │ - Sesiones de usuario │ │
│ │ - Cache de contenido │ │
│ │ - Feature flags │ │
│ │ - Queries read-heavy │ │
│ └───────────────────────┘ │
└─────────────┬───────────────┘
│ escrituras + queries complejos
▼
┌─────────────────────────────┐
│ Central (región única) │
│ ┌───────────────────────┐ │
│ │ Postgres │ │
│ │ - Datos transaccion. │ │
│ │ - Analytics │ │
│ │ - Full-text search │ │
│ │ - Tablas write-heavy │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
SQLite en el edge para el hot path read-heavy. Postgres en una región central para las cosas transaccionales y write-heavy. Las bases SQLite del edge sincronizan desde Postgres via un pipeline CDC o ETL periódico.
Avanzado: Extensiones Custom de libSQL
Una cosa que me empujó hacia Turso fueron las funciones definidas por usuario basadas en WASM de libSQL. Puede escribir funciones custom en cualquier lenguaje que compile a WASM y cargarlas en SQLite:
// distance.rs, compilado a WASM
use libsql_bindgen::*;
#[libsql_bindgen]
pub fn haversine(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
let r = 6371.0; // Radio de la Tierra en km
let dlat = (lat2 - lat1).to_radians();
let dlon = (lon2 - lon1).to_radians();
let a = (dlat / 2.0).sin().powi(2)
+ lat1.to_radians().cos() * lat2.to_radians().cos()
* (dlon / 2.0).sin().powi(2);
let c = 2.0 * a.sqrt().asin();
r * c
}
Después usalo en SQL:
SELECT name, haversine(lat, lon, -34.6037, -58.3816) AS distance_km
FROM stores
WHERE distance_km < 10
ORDER BY distance_km;
Esto corre a velocidad nativa dentro del motor de queries de SQLite. Sin llamadas a servicios externos, sin overhead de UDF de lenguajes interpretados. Para queries geo en bases de datos edge, esto representa un avance significativo.
Realidad Operacional
Seamos honestos sobre las asperezas. SQLite en el edge presenta desafíos:
-
Las migraciones de schema son complicadas. No es posible correr
ALTER TABLE ... ADD COLUMNcon un valor default en una tabla grande sin lockearla. libSQL mejora esto, pero el ALTER TABLE de SQLite vanilla es notoriamente limitado. -
El monitoreo es inmaduro. No hay equivalente de
pg_stat_statements. Teners que instrumentar tu código de aplicación o usar el callbacksqlite3_trace_v2de SQLite. -
Los backups requieren pensar. No es posible simplemente hacer
pg_dump. LiteFS maneja backup continuo a S3, pero la recuperación point-in-time requiere mantener archivos WAL. -
El connection pooling no existe (porque no hay conexiones). Esto en realidad es una feature, no más pesadillas de configuración de PgBouncer, pero significa que tu aplicación debe manejar correctamente el busy timeout de SQLite.
A pesar de estos temas, estoy genuinamente convencido de que SQLite en el edge es la arquitectura correcta para una gran clase de aplicaciones que actualmente están over-provisionando con Postgres o MySQL. Las características de performance son demasiado buenas para ignorar, y el tooling finalmente maduró al punto de estar production-ready.
El mundo de las bases de datos está teniendo un momento de "la herramienta correcta para el trabajo correcto". Postgres es excepcional, pero no todo necesita una base de datos cliente-servidor. A veces la mejor base de datos es la que está embebida en tu aplicación, replicada a cada nodo edge, sirviendo lecturas en microsegundos.