•15 min read

Turso & libSQL: Distributed SQLite with Embedded Replicas for Low-Latency Backends

Turso & libSQL: Distributed SQLite with Embedded Replicas for Low-Latency Backends

Introduction: The Edge Database Imperative

Achieving sub-5ms data access latency for global applications necessitates bringing data closer to the user. Traditional centralized database architectures, even with read replicas, introduce network overhead that becomes prohibitive at the edge. Turso, built on libSQL (a fork of SQLite), offers a compelling solution: distributed SQLite with embedded replicas. This architecture allows for local, low-latency reads while maintaining strong consistency for writes through primary delegation. This guide details the practical implementation of Turso with embedded replicas, covering deployment, latency measurement, write handling, offline synchronization, and migration strategies.

Audio Briefing
0:00 / 0:00
Advertisement

Architecture Overview: Primary-Replica Model with Edge Embedding

Turso's core strength lies in its primary-replica model. A single primary database handles all writes, ensuring ACID compliance and strong consistency. Read replicas, however, can be deployed globally, including directly embedded within edge workers. This embedding is crucial: it eliminates network hops for read operations, achieving local disk-speed access.

Key Architectural Components:

  1. Turso Primary Database: The authoritative source for all data, responsible for write operations and propagating changes to replicas. Typically deployed in a central region.
  2. Turso Read Replicas: Geographically distributed instances of the database, receiving updates from the primary. These can be traditional cloud-hosted replicas or embedded within application processes.
  3. libSQL Client Library: The client-side interface for interacting with Turso databases. Supports both remote connections and local embedded replicas.
  4. Edge Worker/Application: The compute environment (e.g., Cloudflare Workers, Vercel Edge Functions, Fly.io Machines) where the libSQL embedded replica resides.

Data Flow:

  • Writes: All write operations are routed to the Turso primary database. The libSQL client handles this delegation transparently or explicitly.
  • Reads: Read operations are preferentially served by the nearest, or embedded, replica. If an embedded replica is available and up-to-date, reads are local. Otherwise, they fall back to a remote replica or the primary.
  • Replication: The primary asynchronously replicates data to all connected replicas. Turso leverages a write-ahead log (WAL) based replication mechanism, ensuring eventual consistency for replicas.

Setting Up Turso & libSQL

First, install the Turso CLI and create a database.

# Install Turso CLI
curl -sSfL https://get.tur.so/install.sh | bash

# Authenticate
turso auth login

# Create a database in a primary region (e.g., 'ord' for Chicago)
turso db create my-edge-app-db --location ord

# Create a read replica in an edge region (e.g., 'syd' for Sydney)
turso db replicate my-edge-app-db --location syd

# Get connection URL and auth token for the primary
turso db shell my-edge-app-db
# In the shell, run:
# .connection-url
# .auth-token
# Exit the shell

Store the primary database URL and auth token securely. For embedded replicas, we'll use a different connection string.

Initializing libSQL Client

The libsql-client library provides the necessary interface.

// src/lib/turso.ts
import { createClient, Client } from '@libsql/client';

let tursoClient: Client | null = null;

export function getTursoClient(
  url: string = process.env.TURSO_DATABASE_URL!,
  authToken: string = process.env.TURSO_AUTH_TOKEN!
): Client {
  if (!tursoClient) {
    if (!url || !authToken) {
      throw new Error('TURSO_DATABASE_URL and TURSO_AUTH_TOKEN must be set.');
    }
    tursoClient = createClient({
      url,
      authToken,
    });
  }
  return tursoClient;
}

// Example usage (e.g., in an API route)
// const db = getTursoClient();
// const result = await db.execute('SELECT * FROM users');

Embedded Replicas for Sub-5ms Reads

The true power of Turso for edge applications comes from embedding replicas. This means the SQLite database file is managed locally by the edge worker, synchronizing with the primary.

Deploying with Cloudflare Workers (Example)

Cloudflare Workers offer Durable Objects for stateful applications, but for simple embedded replicas, we can leverage the Worker's file system (if available, or a local in-memory/temp file system for ephemeral replicas) or, more practically, use the libsql-client's local replica capabilities.

The libsql-client can be configured to operate in a "local replica" mode, where it maintains a local SQLite file and synchronizes with a remote Turso primary.

// src/lib/turso-edge.ts
import { createClient, Client } from '@libsql/client';
import { fileURLToPath } from 'url';
import path from 'path';

// For Cloudflare Workers, you might need to use a different storage mechanism
// or rely on the remote replica for reads if true local file system access is limited.
// This example assumes a Node.js-like environment where file system access is possible.
// For Workers, consider using a remote replica URL directly or Durable Objects for state.

// In a Node.js environment (e.g., Vercel Edge Functions with Node.js runtime)
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DB_PATH = path.join(__dirname, '../../data/local.db'); // Path to store the local SQLite file

let edgeTursoClient: Client | null = null;

export function getEdgeTursoClient(
  syncUrl: string = process.env.TURSO_DATABASE_URL!, // Primary URL for synchronization
  authToken: string = process.env.TURSO_AUTH_TOKEN!,
  localDbPath: string = DB_PATH // Path for the local SQLite file
): Client {
  if (!edgeTursoClient) {
    if (!syncUrl || !authToken) {
      throw new Error('TURSO_DATABASE_URL and TURSO_AUTH_TOKEN must be set for edge client.');
    }

    // Initialize the client in local replica mode
    edgeTursoClient = createClient({
      url: `file:${localDbPath}`, // Connect to a local SQLite file
      syncUrl, // URL of the primary database for synchronization
      authToken,
      syncInterval: 5000, // Sync every 5 seconds (adjust as needed)
    });

    // Start synchronization immediately
    edgeTursoClient.sync();
  }
  return edgeTursoClient;
}

// Example usage in an edge function
// import { getEdgeTursoClient } from '../lib/turso-edge';
//
// export default async function handler(req: Request) {
//   const db = getEdgeTursoClient();
//   try {
//     const start = performance.now();
//     const result = await db.execute('SELECT * FROM products WHERE category = ?', ['electronics']);
//     const end = performance.now();
//     console.log(`Read latency: ${end - start}ms`);
//     return new Response(JSON.stringify(result.rows), { status: 200 });
//   } catch (error) {
//     console.error('Edge DB error:', error);
//     return new Response('Internal Server Error', { status: 500 });
//   }
// }

Important Considerations for Edge Environments:

  • File System Access: True embedded replicas require persistent file system access. Cloudflare Workers typically do not provide this directly for individual requests. Solutions include:
    • Durable Objects: A Durable Object can manage a libSQL client and its local file, acting as a single-instance replica for a specific scope.
    • Remote Replica Fallback: For environments without persistent local storage, configure the libsql-client to connect directly to the nearest Turso read replica URL. While not "embedded," it's still geographically close.
    • Vercel Edge Functions (Node.js Runtime): These can write to /tmp, which is ephemeral but sufficient for a single function invocation if the replica is initialized and synced per request (less efficient). For persistent state, external storage or a remote replica is preferred.
  • Synchronization Strategy: syncInterval defines how often the local replica pulls changes from the primary. For critical reads, you might trigger db.sync() manually before a read, but this adds latency. Eventual consistency is the norm for edge reads.

Measuring Sub-5ms Local Read Latency

To demonstrate sub-5ms latency, deploy the getEdgeTursoClient example to an environment that supports local file system access (e.g., a local Node.js server, or a Vercel Edge Function with a /tmp path for ephemeral data).

// src/pages/api/products.ts (Example Vercel Edge Function)
import type { NextRequest } from 'next/server';
import { getEdgeTursoClient } from '../../lib/turso-edge';

export const config = {
  runtime: 'edge', // This ensures it runs on the Vercel Edge Network
};

export default async function handler(req: NextRequest) {
  const db = getEdgeTursoClient(); // This will initialize and sync the local replica

  try {
    const start = performance.now();
    // Ensure your database has a 'products' table with some data
    await db.execute(`
      CREATE TABLE IF NOT EXISTS products (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        category TEXT NOT NULL,
        price REAL NOT NULL
      );
    `);
    await db.execute(`
      INSERT OR IGNORE INTO products (id, name, category, price) VALUES
      (1, 'Laptop Pro', 'electronics', 1200.00),
      (2, 'Mechanical Keyboard', 'peripherals', 150.00);
    `);

    const result = await db.execute('SELECT * FROM products WHERE category = ?', ['electronics']);
    const end = performance.now();
    const latency = end - start;

    console.log(`Edge Read Latency: ${latency.toFixed(2)}ms`);

    return new Response(JSON.stringify({
      data: result.rows,
      latency: `${latency.toFixed(2)}ms`,
      source: 'edge_replica'
    }), {
      status: 200,
      headers: {
        'Content-Type': 'application/json',
      },
    });
  } catch (error: any) {
    console.error('Edge DB error:', error);
    return new Response(JSON.stringify({ error: error.message }), {
      status: 500,
      headers: {
        'Content-Type': 'application/json',
      },
    });
  }
}

When running this locally or deploying to Vercel Edge, you will observe read latencies often below 5ms, sometimes even sub-1ms, depending on the query complexity and environment. This is because the data is being read from a local file, not over the network.

Advertisement

Write Delegation to Primary Nodes

All write operations must be directed to the Turso primary database to maintain strong consistency. The libsql-client simplifies this by allowing you to specify a syncUrl for local replicas, which is used for writes.

When using createClient with a file: URL and a syncUrl, the client automatically delegates writes to the syncUrl (the primary).

// src/lib/turso-write.ts
import { getEdgeTursoClient } from './turso-edge'; // Re-use the edge client setup

export async function createProduct(name: string, category: string, price: number) {
  const db = getEdgeTursoClient(); // This client is configured to delegate writes

  try {
    const start = performance.now();
    const result = await db.execute(
      'INSERT INTO products (name, category, price) VALUES (?, ?, ?)',
      [name, category, price]
    );
    const end = performance.now();
    console.log(`Write operation to primary latency: ${end - start}ms`);
    return result;
  } catch (error) {
    console.error('Write error:', error);
    throw error;
  }
}

// Example usage in an API route:
// import { createProduct } from '../../lib/turso-write';
//
// export default async function handler(req: Request) {
//   if (req.method !== 'POST') {
//     return new Response('Method Not Allowed', { status: 405 });
//   }
//   const { name, category, price } = await req.json();
//   try {
//     await createProduct(name, category, price);
//     return new Response('Product created successfully', { status: 201 });
//   } catch (error) {
//     return new Response('Failed to create product', { status: 500 });
//   }
// }

The latency for write operations will naturally be higher than local reads, as they involve a network round trip to the primary database. This is an inherent tradeoff for strong consistency.

Offline-First Synchronization

The embedded replica model inherently supports offline-first capabilities. If an edge worker or client application loses network connectivity, it can continue to serve reads from its local replica. Once connectivity is restored, the libsql-client will automatically attempt to synchronize with the primary.

For client-side applications (e.g., Electron, mobile apps), libsql-client can be used directly to manage a local SQLite database that syncs with Turso.

// Example: Client-side offline-first setup (e.g., in an Electron app)
import { createClient } from '@libsql/client';
import path from 'path';
import { app } from 'electron'; // Assuming Electron context

const userDataPath = app.getPath('userData');
const localDbPath = path.join(userDataPath, 'my-app-offline.db');

const offlineClient = createClient({
  url: `file:${localDbPath}`,
  syncUrl: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!,
  syncInterval: 10000, // Sync every 10 seconds when online
});

// Start synchronization
offlineClient.sync();

// Application logic can now read/write to offlineClient
// Writes will be queued and sent to primary when online.
// Reads will be served from local DB.

This setup provides resilience and a smooth user experience even with intermittent network access.

Migrating from Neon/Supabase PostgreSQL

Migrating from a PostgreSQL-based service like Neon or Supabase to Turso involves schema conversion and data transfer.

1. Schema Conversion

SQLite's SQL dialect is largely compatible with PostgreSQL, but there are differences:

  • Data Types: PostgreSQL's TEXT, VARCHAR, INTEGER, BOOLEAN map well. UUID becomes TEXT. JSONB becomes TEXT (store as JSON string). ARRAY types need to be serialized (e.g., JSON string).
  • Auto-incrementing IDs: PostgreSQL uses SERIAL or GENERATED BY DEFAULT AS IDENTITY. SQLite uses INTEGER PRIMARY KEY AUTOINCREMENT.
  • Functions: Many PostgreSQL-specific functions (e.g., GEN_RANDOM_UUID(), NOW()) need SQLite equivalents or application-level handling.
  • Constraints: CHECK constraints, FOREIGN KEY constraints are supported, but behavior might differ slightly.
  • Indexes: Standard CREATE INDEX syntax is compatible.

Example Schema Conversion:

-- PostgreSQL Schema
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email TEXT UNIQUE NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Turso (SQLite) Schema
CREATE TABLE users (
    id TEXT PRIMARY KEY, -- UUIDs stored as TEXT
    email TEXT UNIQUE NOT NULL,
    created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) -- ISO 8601 format
);

2. Data Export & Import

  1. Export from PostgreSQL: Use pg_dump to export data in a format suitable for import. CSV is often easiest.

    pg_dump -d your_db_name -t users --data-only --column-inserts > users_data.sql
    

    Or, for CSV:

    COPY users TO '/tmp/users.csv' WITH (FORMAT CSV, HEADER);
    
  2. Import to Turso:

    • CSV Import: If you exported to CSV, you can write a script to read the CSV and insert into Turso.

      // src/scripts/importUsers.ts
      import fs from 'fs';
      import csv from 'csv-parser';
      import { getTursoClient } from '../lib/turso'; // Use the primary client
      
      async function importUsers() {
        const db = getTursoClient();
        const users: any[] = [];
      
        fs.createReadStream('/tmp/users.csv')
          .pipe(csv())
          .on('data', (row) => {
            users.push(row);
          })
          .on('end', async () => {
            console.log(`Importing ${users.length} users...`);
            for (const user of users) {
              await db.execute(
                'INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)',
                [user.id, user.email, user.created_at]
              );
            }
            console.log('Users imported successfully.');
          });
      }
      
      importUsers().catch(console.error);
      
    • SQL Import: If you used pg_dump with INSERT statements, you might need to manually adjust the SQL for SQLite compatibility (e.g., UUID to TEXT for IDs, NOW() to strftime). Then, you can execute the SQL file via the Turso CLI or libsql-client.

      # Via Turso CLI
      turso db shell my-edge-app-db < users_data_sqlite_compatible.sql
      

Comparison: Turso (libSQL) vs. PostgreSQL (Neon/Supabase)

FeatureTurso (libSQL)PostgreSQL (Neon/Supabase)
ArchitecturePrimary-Replica, Embedded Edge ReplicasPrimary-Replica, Logical Replication
Read Latency (Edge)Sub-5ms (local file access)10-50ms+ (network to nearest replica)
Write Latency20-100ms+ (network to primary)20-100ms+ (network to primary)
Consistency ModelStrong (writes), Eventual (reads from replicas)Strong (all operations)
Data ModelRelational (SQLite dialect)Relational (PostgreSQL dialect), JSONB
Offline SupportExcellent (local file sync)Limited (requires client-side caching/sync layer)
ScalabilityReads scale horizontally with replicasReads scale horizontally with replicas
ComplexitySimpler for edge, manages replication internallyMore complex for edge, requires external sync for offline
Cost ModelUsage-based (reads/writes/storage)Usage-based (compute/storage/data transfer)

Production Gotchas & Troubleshooting

  1. "Database is locked" errors:
    • Cause: SQLite is a file-based database. Concurrent writes from multiple processes or threads to the same local SQLite file can cause locking issues.
    • Fix:
      • Ensure only one libsql-client instance is managing a specific local database file.
      • For high-concurrency edge environments, consider using a remote Turso replica URL directly instead of a local file, or leverage Durable Objects for single-writer guarantees.
      • Configure a higher busy timeout parameter if connecting with a local file: createClient({ url: 'file:my.db?busy_timeout=5000' }).
  2. Stale Reads from Edge Replicas:
    • Cause: The syncInterval is too high, or the replica hasn't synced recently.
    • Fix:
      • Reduce syncInterval for more up-to-date data.
      • Manually trigger db.sync() before critical reads if eventual consistency is not acceptable for a specific query.
      • For reads requiring absolute freshness, route them directly to the primary database.
  3. TURSO_DATABASE_URL or TURSO_AUTH_TOKEN missing:
    • Cause: Environment variables are not correctly set in the deployment environment (e.g., Vercel, Cloudflare, Fly.io).
    • Fix: Double-check environment variable configuration for your specific platform. Ensure they are accessible at runtime.
  4. file: URL issues in serverless/edge:
    • Cause: Serverless functions often have ephemeral or restricted file systems. Writing to arbitrary paths might fail or be lost between invocations.
    • Fix:
      • For Cloudflare Workers, use Durable Objects to manage persistent state for the replica.
      • For Vercel Edge Functions, /tmp is writable but ephemeral. This means the replica will re-sync on every cold start, increasing latency for the first few requests.
      • If true local persistence is not feasible, connect directly to a remote Turso read replica URL instead of a file: URL. This still provides geographic proximity.
  5. Write latency is too high:
    • Cause: The primary database is geographically distant from your edge function, or the network path is congested.
    • Fix:
      • Ensure your Turso primary is in a region geographically close to the majority of your write traffic.
      • Optimize your application to minimize write operations, or batch them where possible.
      • Consider a different consistency model (e.g., CRDTs) if strong consistency for writes is not strictly required and lower write latency is paramount, though this adds significant complexity.

Frequently Asked Questions

  1. Can I use Turso for analytical queries? Yes, Turso supports standard SQL. For complex analytical queries, you can run them against a dedicated read replica or the primary. However, for very large-scale analytics, a dedicated OLAP solution might be more appropriate.
  2. How does Turso handle schema migrations? Schema migrations are applied to the primary database. Replicas will eventually catch up. You can use standard SQL ALTER TABLE statements. For more complex migrations, consider tools like sqldef or custom scripts.
  3. What happens if the primary database goes down? Writes will fail. Reads from existing, synchronized replicas will continue to function, but they will become increasingly stale. Turso provides high availability for primaries, but in a catastrophic event, write availability will be impacted until the primary is restored or a new primary is promoted.
  4. Is Turso suitable for high-transactional workloads? Turso excels at high-read, low-latency workloads at the edge. For extremely high-transactional write workloads (thousands of writes per second to a single table), the single-primary model might become a bottleneck. However, for most web applications, Turso's primary can handle significant write throughput.
  5. How do I monitor my Turso databases? Turso provides a dashboard with metrics for read/write operations, storage, and replication status. You can also integrate with external monitoring tools by collecting logs and metrics from your application instances.
Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement