Fix: Turso Not Working — Connection Refused, Queries Returning Empty, or Embedded Replicas Not Syncing
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Turso database issues — libsql client setup, connection URLs and auth tokens, embedded replicas for local-first apps, schema migrations, Drizzle ORM integration, and edge deployment.
The Problem
Connecting to Turso fails with an authentication error:
import { createClient } from '@libsql/client';
const client = createClient({
url: 'libsql://my-database-myorg.turso.io',
authToken: process.env.TURSO_AUTH_TOKEN,
});
const result = await client.execute('SELECT 1');
// Error: AUTHORIZATION_FAILED: Token is not validOr queries return empty results even though data exists:
const users = await client.execute('SELECT * FROM users');
console.log(users.rows); // []
// But the Turso CLI shows data in the tableOr embedded replicas fail to sync:
Error: Sync failed: unable to open database fileOr the database works locally but fails on Cloudflare Workers or Vercel Edge:
Error: Dynamic require of "node:fs" is not supportedWhy This Happens
Turso is a distributed SQLite database built on libSQL. It uses an HTTP-based protocol for remote connections and supports local embedded replicas. The combination of SQLite semantics (which most developers know) with a distributed network layer (which they don’t) is where most Turso bugs hide.
The libSQL client supports three transport modes — HTTP, WebSocket, and embedded replica — and the one you get depends entirely on how you import it and what URL you pass. The default @libsql/client import bundles all three and picks at runtime based on the URL scheme. But edge runtimes can only use HTTP, Node servers usually want WebSocket for lower latency on transactions, and local-first apps need the embedded mode. Picking the wrong transport for your platform leads to either deployment crashes (Node APIs not available on Workers) or silently slow query patterns (HTTP fallback when you wanted WebSocket).
The auth model adds a second layer. Tokens are issued per database or per group, with optional read-only and expiration flags. A token generated for db-a won’t work on db-b even if both belong to the same organization. Schema migrations introduce yet another wrinkle: Turso supports embedded replicas with their own local schema, so a migration that runs against the remote may not propagate to a replica until the next sync, and applications can transiently see two different schemas during deploys.
- Auth tokens are scoped and expire — Turso tokens can be scoped to specific databases and have expiration times. A token generated for one database won’t work with another. Expired tokens return authorization errors without a clear expiration message.
- Remote queries go through the HTTP protocol —
libsql://URLs connect over HTTPS to Turso’s edge network. Network issues, DNS resolution failures, or incorrect URLs result in connection errors that look similar to authentication failures. - Embedded replicas need a local file path — when using embedded replicas (
syncUrl+urlpointing to a local file), libSQL creates a local SQLite file and syncs from the remote. If the directory doesn’t exist or the process doesn’t have write permissions, the sync fails silently and queries return stale or empty results. - Edge runtimes have no filesystem — Cloudflare Workers, Vercel Edge, and Deno Deploy can’t use embedded replicas because they have no persistent local filesystem. Use the HTTP client (
@libsql/client/http) for edge environments.
Fix 1: Connect to Turso Correctly
npm install @libsql/client# Create a database with the Turso CLI
turso db create my-app-db
# Get the connection URL
turso db show my-app-db --url
# → libsql://my-app-db-myorg.turso.io
# Create an auth token
turso db tokens create my-app-db
# → eyJhbGciOiJ...
# For read-only tokens
turso db tokens create my-app-db --read-only
# For expiring tokens
turso db tokens create my-app-db --expiration 7d// Basic remote connection
import { createClient } from '@libsql/client';
const client = createClient({
url: process.env.TURSO_DATABASE_URL!, // libsql://my-db-org.turso.io
authToken: process.env.TURSO_AUTH_TOKEN!, // eyJhbGciOiJ...
});
// Verify connection
const result = await client.execute('SELECT 1 AS ok');
console.log(result.rows[0]); // { ok: 1 }
// Basic CRUD operations
// Create table
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`);
// Insert with parameters — always use parameters, never string interpolation
await client.execute({
sql: 'INSERT INTO users (name, email) VALUES (?, ?)',
args: ['Alice', '[email protected]'],
});
// Named parameters
await client.execute({
sql: 'INSERT INTO users (name, email) VALUES (:name, :email)',
args: { name: 'Bob', email: '[email protected]' },
});
// Select
const users = await client.execute('SELECT * FROM users');
for (const row of users.rows) {
console.log(row.id, row.name, row.email);
}
// Select with filtering
const user = await client.execute({
sql: 'SELECT * FROM users WHERE email = ?',
args: ['[email protected]'],
});Fix 2: Transactions and Batch Operations
// Transaction — atomic multi-statement execution
const tx = await client.transaction('write');
try {
await tx.execute({
sql: 'INSERT INTO orders (user_id, total) VALUES (?, ?)',
args: [1, 99.99],
});
const orderResult = await tx.execute('SELECT last_insert_rowid() AS id');
const orderId = orderResult.rows[0].id;
await tx.execute({
sql: 'INSERT INTO order_items (order_id, product_id, quantity) VALUES (?, ?, ?)',
args: [orderId, 42, 2],
});
await tx.commit();
} catch (error) {
await tx.rollback();
throw error;
}
// Batch — multiple statements in a single round trip (more efficient)
const batchResult = await client.batch([
{
sql: 'INSERT INTO users (name, email) VALUES (?, ?)',
args: ['Charlie', '[email protected]'],
},
{
sql: 'INSERT INTO users (name, email) VALUES (?, ?)',
args: ['Diana', '[email protected]'],
},
'SELECT * FROM users ORDER BY id DESC LIMIT 2',
], 'write');
// batchResult is an array — one result per statement
const insertedUsers = batchResult[2].rows;
// Conditional batch (statements depend on previous results)
const conditionalBatch = await client.batch([
{
sql: 'SELECT id FROM users WHERE email = ?',
args: ['[email protected]'],
},
// This runs regardless — batches don't support conditional logic
// Use transactions for conditional operations
], 'read');Fix 3: Embedded Replicas (Local-First)
Embedded replicas keep a local SQLite copy that syncs from Turso:
import { createClient } from '@libsql/client';
// Embedded replica — local file + remote sync
const client = createClient({
url: 'file:./local-replica.db', // Local SQLite file
syncUrl: process.env.TURSO_DATABASE_URL!, // Remote Turso URL
authToken: process.env.TURSO_AUTH_TOKEN!,
syncInterval: 60, // Sync every 60 seconds
});
// Manual sync — call after writes or when you need fresh data
await client.sync();
// Reads hit the local file (fast, works offline)
const users = await client.execute('SELECT * FROM users');
// Writes go to the remote and then sync back
await client.execute({
sql: 'INSERT INTO users (name, email) VALUES (?, ?)',
args: ['Eve', '[email protected]'],
});
// Sync after write to update the local replica
await client.sync();When to use embedded replicas:
- Server-side apps (Node.js, Bun) where you want fast reads
- Desktop apps (Electron, Tauri) for offline-first capability
- Self-hosted servers that need local SQLite performance with remote backup
When NOT to use embedded replicas:
- Edge runtimes (Cloudflare Workers, Vercel Edge) — no filesystem
- Serverless functions — ephemeral containers lose the local file
- If you need real-time sync across multiple instances
Fix 4: Edge Runtime Compatibility
Use the HTTP-only client for edge environments:
// For Cloudflare Workers, Vercel Edge, Deno Deploy
import { createClient } from '@libsql/client/http';
// HTTP client — no filesystem dependency
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
// Works exactly the same as the regular client
const users = await client.execute('SELECT * FROM users');Cloudflare Workers:
// src/index.ts — Cloudflare Worker
import { createClient } from '@libsql/client/http';
interface Env {
TURSO_DATABASE_URL: string;
TURSO_AUTH_TOKEN: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const client = createClient({
url: env.TURSO_DATABASE_URL,
authToken: env.TURSO_AUTH_TOKEN,
});
const result = await client.execute('SELECT * FROM users LIMIT 10');
return new Response(JSON.stringify(result.rows), {
headers: { 'Content-Type': 'application/json' },
});
},
};Next.js Edge API Route:
// app/api/users/route.ts
import { createClient } from '@libsql/client/http';
export const runtime = 'edge';
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
export async function GET() {
const result = await client.execute('SELECT * FROM users');
return Response.json(result.rows);
}Fix 5: Drizzle ORM Integration
npm install drizzle-orm @libsql/client
npm install -D drizzle-kit// src/db/schema.ts — define schema
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
email: text('email').notNull().unique(),
role: text('role', { enum: ['admin', 'user'] }).default('user'),
createdAt: text('created_at').default('CURRENT_TIMESTAMP'),
});
export const posts = sqliteTable('posts', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
body: text('body').notNull(),
authorId: integer('author_id')
.notNull()
.references(() => users.id),
publishedAt: text('published_at'),
});// src/db/index.ts — create Drizzle instance
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
import * as schema from './schema';
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
export const db = drizzle(client, { schema });// src/db/queries.ts — type-safe queries
import { db } from './index';
import { users, posts } from './schema';
import { eq, like, desc } from 'drizzle-orm';
// Select all users
const allUsers = await db.select().from(users);
// Select with filter
const admins = await db.select().from(users).where(eq(users.role, 'admin'));
// Insert
const newUser = await db.insert(users).values({
name: 'Alice',
email: '[email protected]',
role: 'admin',
}).returning();
// Update
await db.update(users)
.set({ role: 'admin' })
.where(eq(users.email, '[email protected]'));
// Delete
await db.delete(users).where(eq(users.id, 1));
// Join
const postsWithAuthors = await db
.select({
postTitle: posts.title,
authorName: users.name,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.orderBy(desc(posts.publishedAt));// drizzle.config.ts — for migrations
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'turso',
dbCredentials: {
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
},
} satisfies Config;# Generate migration SQL
npx drizzle-kit generate
# Push schema directly (development)
npx drizzle-kit push
# Open Drizzle Studio (database browser)
npx drizzle-kit studioFix 6: Multi-Database and Branching
Turso supports per-tenant databases and database branching:
# Create a database per tenant
turso db create tenant-acme
turso db create tenant-globex
# Branch a database for testing/staging
turso db create my-app-staging --from-db my-app-production// Multi-tenant — create client per request
function getClientForTenant(tenantId: string) {
return createClient({
url: `libsql://${tenantId}-db-myorg.turso.io`,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
}
// Usage in an API handler
async function handleRequest(tenantId: string) {
const client = getClientForTenant(tenantId);
const users = await client.execute('SELECT * FROM users');
return users.rows;
}
// Group databases for shared auth tokens
// turso group create my-group
// turso db create my-db --group my-group
// Token created for the group works for all databases in itFix 7: Transport and Platform-Specific Behavior
The libSQL client supports three transport modes, and each platform has constraints about which ones it can use.
HTTP vs WebSocket transport:
The default @libsql/client import auto-selects WebSocket when running on Node and HTTP when running on edge runtimes. WebSocket gives lower latency for transactions because the connection stays open across multiple statements; HTTP closes after each request and adds a round trip per query. Force one or the other explicitly when you need consistent behavior:
// Force HTTP — works everywhere, slightly higher latency
import { createClient } from '@libsql/client/http';
// Force WebSocket — Node/Bun only, lower latency for transactions
import { createClient } from '@libsql/client/ws';
// Auto-detect (default)
import { createClient } from '@libsql/client';Cloudflare Workers (HTTP-only):
Workers cannot open arbitrary TCP or WebSocket connections, so @libsql/client/http is the only option. The HTTP client doesn’t support transaction() — every statement is a separate request. For atomic multi-statement work, use batch() instead, which sends all statements in one HTTP round trip and applies them atomically server-side. For Worker-specific debugging see Fix: Cloudflare D1 Not Working, which covers the same SQLite-over-HTTP constraints.
Vercel Functions and Vercel Edge:
Standard Vercel Functions (Node runtime) can use WebSocket — they have a long-lived process for the warm container’s lifetime. Vercel Edge Functions cannot — they’re built on Workers-style isolates and require the HTTP client. The same code with runtime: 'edge' and without will work differently, which is a common source of “works locally, fails in production” reports. See Fix: Vercel Edge Function Not Working for the broader edge runtime constraints.
Embedded replicas per environment:
Embedded replicas need a writable filesystem and a long-lived process, so they fit:
- Long-running Node servers (Express, Fastify, NestJS)
- Bun servers
- Desktop apps (Electron, Tauri)
- Self-hosted backends with persistent disks (Fly.io volumes, AWS EC2)
They don’t fit:
- Serverless functions (Lambda, Vercel Functions) — ephemeral filesystem, replica lost between invocations
- Cloudflare Workers, Vercel Edge, Deno Deploy — no filesystem
- Stateless containers without volumes — replica recreated on every restart
If you do use embedded replicas in a containerized setup, mount a persistent volume at the replica path and run client.sync() on a schedule. Without the volume, every cold start re-downloads the entire database, which gets expensive fast.
Schema migration coordination:
When you run a migration against the remote (via Drizzle Kit, libsql-stateful-migrations, or raw SQL), embedded replicas don’t pick up the schema change until their next sync. If your deploy ships new application code that expects the new schema but the local replica still has the old one, queries fail with “no such column” until the sync interval ticks. Two patterns work:
// 1. Force a sync immediately on startup, before serving traffic
await client.sync();
app.listen(3000);
// 2. Set syncInterval to a small value during deploys, then back to normal
const client = createClient({
url: 'file:./local-replica.db',
syncUrl: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
syncInterval: 5, // 5 seconds during rollouts
});Connection pool comparison:
Unlike Postgres-backed setups where you fight pool exhaustion (see Fix: Prisma Connection Pool Exhausted for the typical failure mode), libSQL’s HTTP transport has no per-connection pool to exhaust — each query is an independent request. That’s a major benefit for serverless. The tradeoff is per-query latency overhead, which is why the WebSocket and embedded options exist for latency-sensitive workloads.
Still Not Working?
“Token is not valid” but the token was just created — check that the token matches the database. Tokens are scoped to a specific database or group. If you created the token for db-a but are connecting to db-b, it won’t work. Recreate the token: turso db tokens create <correct-db-name>. Also check that the environment variable doesn’t have extra whitespace or newline characters.
Queries return empty but data exists in the CLI — you might be connected to a different database. Verify the URL matches: turso db show <db-name> --url. Also check if the table name has different casing — SQLite is case-insensitive for table names but the schema might differ. Run SELECT name FROM sqlite_master WHERE type='table' to list tables.
Embedded replica sync fails — the directory for the local file must exist and be writable. file:./data/replica.db requires the ./data/ directory to exist. Create it before connecting. Also, the syncUrl must use the libsql:// or https:// protocol — file:// is only for the local path.
“Dynamic require of node:fs” on edge runtime — you’re importing @libsql/client instead of @libsql/client/http. The default import includes the embedded replica support which needs Node.js fs. Switch to import { createClient } from '@libsql/client/http' for edge runtimes.
transaction() returns “not supported” on Workers — the HTTP transport doesn’t support interactive transactions. Replace client.transaction() blocks with client.batch(), which is server-side atomic. The conversion is mechanical: collect every statement into an array and send them all at once instead of awaiting each.
Embedded replica reads old data after a remote write — client.sync() only runs on the interval you set or when you call it explicitly. Writes via the same client are visible immediately (they go to the remote and bypass the local cache for the next read), but writes via a different client or the dashboard don’t appear until sync. Call await client.sync() before reads where you need fresh data, or shorten syncInterval.
Drizzle migrations break embedded replicas during deploys — see the schema migration coordination section above. The common pattern is to apply migrations before deploying new application code, then force a sync on startup. See Fix: Drizzle ORM Not Working for migration-specific debugging.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Neon Database Not Working — Connection Timeout, Branching Errors, or Serverless Driver Issues
How to fix Neon Postgres issues — connection string setup, serverless HTTP driver vs TCP, database branching, connection pooling, Drizzle and Prisma integration, and cold start optimization.
Fix: Upstash Not Working — Redis Commands Failing, Rate Limiter Not Blocking, or QStash Messages Lost
How to fix Upstash issues — Redis REST client setup, rate limiting with @upstash/ratelimit, QStash message queues, Kafka topics, Vector search, and edge runtime integration.
Fix: Convex Not Working — Query Not Updating, Mutation Throwing Validation Error, or Action Timing Out
How to fix Convex backend issues — query/mutation/action patterns, schema validation, real-time reactivity, file storage, auth integration, and common TypeScript type errors.
Fix: Kysely Not Working — Type Errors on Queries, Migration Failing, or Generated Types Not Matching Schema
How to fix Kysely query builder issues — database interface definition, dialect setup, type-safe joins and subqueries, migration runner, kysely-codegen for generated types, and common TypeScript errors.