Turso SERVER_ERROR: 404 Right After Creating a Database
You just created a Turso database, the Platform API confirmed it exists, and the very next query returns SERVER_ERROR: Server returned HTTP status 404. The error is real, it lasts a couple of seconds, and it has nothing to do with your credentials.
This bites hardest in serverless code, where "create, then immediately use" is a natural pattern — a Cloudflare Worker that provisions a database and runs its first migration in the same request. The failure mode is documented in an open issue on tursodatabase/libsql-client-ts (tracked since June 2026): the database is reported as created before it is actually reachable over HTTP.
The error
The failure is a 404 with a SERVER_ERROR prefix, returned by the database HTTP endpoint for roughly 2.5 seconds after the Platform API reports success. A naive first query looks like this — and races the provisioning step:
// ❌ Races the create→serve gap
const res = await fetch(`https://${db}-${org}.turso.io/v2/pipeline`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
requests: [{ type: "execute", stmt: { sql: "SELECT 1" } }],
}),
});
// → HTTP 404 "SERVER_ERROR: Server returned HTTP status 404"
Why it happens
Turso databases are files, not processes — but provisioning a new database still involves moving that file into the serving path. The Platform API (which answers your POST /v1/organizations/.../databases call) and the database HTTP endpoint (POST /v2/pipeline) are separate services, and there is no readiness signal exposed between them. The create call returns success before the pipeline endpoint can serve the new database.
This is a consistency gap, not a bug in your code: the same query succeeds a moment later. The production-grade fix is not to pray and retry — it is to treat readiness as a first-class state and poll for it with backoff, exactly as you would for any eventually-consistent dependency.
The fix — a readiness poll with backoff
Probe the database's own HTTP endpoint with a harmless SELECT 1, retry with exponential backoff, and — critically — surface real errors instead of masking them. Only 404 is "not ready yet"; anything else is a genuine failure you need to see:
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export const waitForDatabase = async (
dbUrl,
token,
{ attempts = 10, baseDelayMs = 150, maxDelayMs = 3000 } = {},
) => {
for (let i = 0; i < attempts; i++) {
let res;
try {
res = await fetch(`${dbUrl}/v2/pipeline`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
requests: [{ type: "execute", stmt: { sql: "SELECT 1" } }],
}),
});
} catch {
// transient network failure (DNS propagation, connection reset) — retryable, like 404
await sleep(Math.min(baseDelayMs * 2 ** i, maxDelayMs));
continue;
}
if (res.ok) return; // database is serving
if (res.status !== 404) {
// Real failure — surface it, never swallow it
throw new Error(`pipeline readiness check failed: HTTP ${res.status} ${await res.text()}`);
}
// 404 = still provisioning → exponential backoff with a ceiling
await sleep(Math.min(baseDelayMs * 2 ** i, maxDelayMs));
}
throw new Error(
`database not ready after ${attempts} attempts — check the HTTP URL and org slug`,
);
};
Using it from a Cloudflare Worker with Hono
Wire it into your provisioning route so the create→serve gap is absorbed before the first real query. The same function works in any Worker, Hono handler, or plain Node script — it is dependency-free by design:
import { Hono } from "hono";
import { waitForDatabase } from "./readiness";
const app = new Hono<{
Bindings: { TURSO_URL: string; TURSO_AUTH_TOKEN: string };
}>();
app.post("/provision", async (c) => {
// ... create the database via the Platform API here ...
await waitForDatabase(c.env.TURSO_URL, c.env.TURSO_AUTH_TOKEN);
const res = await fetch(`${c.env.TURSO_URL}/v2/pipeline`, {
method: "POST",
headers: {
Authorization: `Bearer ${c.env.TURSO_AUTH_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
requests: [{ type: "execute", stmt: { sql: "CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, at TEXT)" } }],
}),
});
if (!res.ok) throw new Error(`first query failed: HTTP ${res.status}`);
return c.json({ ok: true }, 201);
});
Two production notes. First, keep the readiness probe's timeout bounded — a hung fetch should fail the request rather than pin a Worker. Second, every response your Worker returns should carry security headers (X-Content-Type-Options: nosniff, X-Frame-Options, and a Content-Security-Policy where applicable); provisioning endpoints are prime reconnaissance targets.
Prevention checklist
- Create → poll → use. Never query a freshly created database without the readiness gate.
- Confirm the HTTP URL with
turso db show <name> --http-urlbefore wiring secrets — a wrong org slug produces the same 404 shape. - Scope tokens to the organization; never log them, never put them in client code.
- Re-verify after Turso upgrades — the provisioning path has changed before and will change again.
Sources
- Open issue: tursodatabase/libsql-client-ts #346 — "create→serve gap, no readiness signal"
- Turso SQL over HTTP reference: docs.turso.tech/sdk/http/reference (
POST /v2/pipeline, Bearer auth) - Turso quickstart (raw fetch pattern): docs.turso.tech/sdk/http/quickstart