Build a serverless API on SQLite with Cloudflare D1

Contents

Cloudflare D1 runs SQLite at the edge as a serverless database. Your REST API gets single-digit millisecond reads and no servers to run. The free tier covers 5 GB of storage and 5 million row reads a day. Deploy a Cloudflare Worker that talks to D1 in plain SQL, and you sit on the most deployed database engine on Earth.

Key Takeaways

  • Your database sits in 300 data centers, so nearby reads take half a millisecond.
  • The free tier covers 5 GB of storage and 5 million reads a day.
  • One database answers roughly 1,000 queries a second, and that is the ceiling.
  • A missing index burns through the free tier fast, because scans count as reads.
  • One command rewinds the whole database to any minute in the past week.

Below is the working code and the numbers. You get the Wrangler setup, a CRUD API written with Hono , and the query patterns that stay fast. Then come the limits that decide whether D1 fits your project.

Why SQLite on the edge makes sense

SQLite has been an embedded-only database for 25 years. It lives inside every iPhone, every Android phone, every browser, and most desktop apps that store data. The SQL dialect is well known, the file format is stable, and the test suite is famously thorough. The one thing SQLite has never done well is talk to a network. It is a C library that opens a file on disk. Nothing inside it listens on a port.

D1 closes that gap. Cloudflare runs SQLite instances on its edge network of more than 300 data centers. A binding object hands them to Workers . Reads hit the nearest replica, so p50 latency for a primary-key lookup lands near 0.5 ms in the same region. Writes go to one primary and replicate outward. That gives you strong consistency on the primary and eventual consistency on the replicas. Next to a normal Postgres or MySQL setup, you skip pool sizing, instance types, failover config, and the pg_hba.conf file. The binding shows up on env.DB, and you start writing SQL.

Cloudflare D1 announcement banner showing the D1 SQL database product
Cloudflare D1: SQLite as a managed serverless database at the edge
Image: Cloudflare Blog: Announcing D1

A Worker in Frankfurt serves a browser in Berlin. It queries a D1 read replica in the same region and returns the response without leaving the EU. A Worker in São Paulo issuing a write talks to the single primary. The primary then pushes the new state out to every read replica.

Architecture diagram showing clients hitting Workers in three regions, reads going to local D1 replicas, writes going to a single D1 primary, and async replication fanning back out

That design has a cost. SQLite is still single-threaded per database, so D1 runs each database’s queries one at a time. At roughly 1 ms per query, the ceiling sits near 1,000 queries per second per database. That is plenty for content APIs and internal tools. It is the wrong shape for write-heavy transactional systems.

SQLite logo
Image: SQLite logo on Wikimedia Commons , public domain

Setting up D1 and your first Worker

The Wrangler CLI handles the whole setup. Install it globally or call it through npx:

npm install -g wrangler
wrangler login

The login command opens your browser. You approve the OAuth grant, and Wrangler stores a token under ~/.wrangler. Create a database next:

wrangler d1 create my-api-db

Wrangler prints a TOML snippet with the database ID. Paste it into wrangler.toml so the Worker knows which database to bind:

name = "posts-api"
main = "src/index.ts"
compatibility_date = "2026-07-01"

[[d1_databases]]
binding = "DB"
database_name = "my-api-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

The binding = "DB" line is the contract between config and code. Inside the Worker, env.DB is a D1Database object no matter what the database is named.

Define a schema in schema.sql:

CREATE TABLE IF NOT EXISTS posts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  created_at INTEGER NOT NULL DEFAULT (unixepoch())
);

CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC);

Apply it once locally and once remotely. The --local flag targets the Miniflare-backed D1 that wrangler dev uses. Drop the flag and the change goes to production:

wrangler d1 execute my-api-db --local --file=schema.sql
wrangler d1 execute my-api-db --remote --file=schema.sql

Seed a row to confirm everything connects:

wrangler d1 execute my-api-db --local \
  --command="INSERT INTO posts (title, content) VALUES ('Hello D1', 'First post');"

wrangler dev starts a local Workers runtime pointed at the local D1 file. You can iterate without touching Cloudflare’s network.

Building a REST API with Hono

You can write a Worker as one fetch handler with a chain of if statements. Routing five HTTP methods across three URL patterns by hand gets ugly fast. Hono is a small, web-standards web framework . It compiles down to a few kilobytes and adds a familiar router on top of the Workers runtime.

Hono framework logo
Hono: a small, fast web framework for the edge
Image: honojs/hono on GitHub , MIT License

Install it and write the API:

npm install hono
import { Hono } from 'hono';

type Env = { DB: D1Database };
const app = new Hono<{ Bindings: Env }>();

app.get('/posts', async (c) => {
  const { results } = await c.env.DB
    .prepare('SELECT id, title, content, created_at FROM posts ORDER BY created_at DESC LIMIT 20')
    .all();
  return c.json(results);
});

app.get('/posts/:id', async (c) => {
  const id = c.req.param('id');
  const row = await c.env.DB
    .prepare('SELECT id, title, content, created_at FROM posts WHERE id = ?')
    .bind(id)
    .first();
  if (!row) return c.json({ error: 'not found' }, 404);
  return c.json(row);
});

app.post('/posts', async (c) => {
  const body = await c.req.json<{ title: string; content: string }>();
  if (!body.title || !body.content) {
    return c.json({ error: 'title and content required' }, 400);
  }
  const result = await c.env.DB
    .prepare('INSERT INTO posts (title, content) VALUES (?, ?) RETURNING id')
    .bind(body.title, body.content)
    .first<{ id: number }>();
  return c.json({ id: result?.id }, 201);
});

app.put('/posts/:id', async (c) => {
  const id = c.req.param('id');
  const body = await c.req.json<{ title: string; content: string }>();
  const result = await c.env.DB
    .prepare('UPDATE posts SET title = ?, content = ? WHERE id = ?')
    .bind(body.title, body.content, id)
    .run();
  if (result.meta.changes === 0) return c.json({ error: 'not found' }, 404);
  return c.json({ updated: result.meta.changes });
});

app.delete('/posts/:id', async (c) => {
  const id = c.req.param('id');
  const result = await c.env.DB
    .prepare('DELETE FROM posts WHERE id = ?')
    .bind(id)
    .run();
  if (result.meta.changes === 0) return c.json({ error: 'not found' }, 404);
  return c.json({ deleted: result.meta.changes });
});

export default app;

A few details in that code are easy to skim past. The .bind() call is the only safe way to put user input into a query. D1 hands bound values to SQLite as prepared statement parameters, so SQL injection is off the table. Building queries with template strings is the same mistake here that it was in PHP back in 2008. The RETURNING clause on the INSERT is standard SQLite from version 3.35 on, and it saves a follow-up SELECT last_insert_rowid() round-trip. Use .first() for a single row or null, .all() for the full result set, and .run() on writes when you want metadata such as meta.changes and meta.last_row_id.

Deploy with one command:

wrangler deploy

The Worker shows up at posts-api.<your-account>.workers.dev within a few seconds. To use your own domain, add a route in wrangler.toml and map it through Cloudflare DNS.

Querying D1 effectively

D1 supports almost all of SQLite’s SQL surface. The network boundary changes which patterns are cheap and which are costly.

Each call to .run() or .all() is a round-trip from the Worker to the D1 instance, so batching related statements saves real time. When you need to insert a parent row and several child rows, build a single batch:

const stmts = [
  c.env.DB.prepare('INSERT INTO posts (title, content) VALUES (?, ?)').bind(title, content),
  c.env.DB.prepare('INSERT INTO tags (post_id, name) VALUES (last_insert_rowid(), ?)').bind('hono'),
  c.env.DB.prepare('INSERT INTO tags (post_id, name) VALUES (last_insert_rowid(), ?)').bind('d1'),
];
await c.env.DB.batch(stmts);

batch() runs the statements in one implicit transaction. If any statement fails, the rest roll back. There is no BEGIN/COMMIT to manage by hand.

Use keyset pagination instead of OFFSET. A query like SELECT * FROM posts ORDER BY id LIMIT 20 OFFSET 10000 makes SQLite walk and throw away 10,000 rows first. Replace it with WHERE id < ? ORDER BY id DESC LIMIT 20. The parameter is the last id from the page before. Speed stays flat no matter how deep the user scrolls.

When you need text search, reach for FTS5. The module is built into D1. One virtual table gets you a usable inverted index:

CREATE VIRTUAL TABLE posts_fts USING fts5(title, content, content='posts', content_rowid='id');

Triggers on the base table keep the index fresh. A query like SELECT * FROM posts_fts WHERE posts_fts MATCH 'serverless' ORDER BY rank returns ranked results in milliseconds. Heavier workloads will outgrow FTS5 and need a real search engine. For the first hundred thousand documents, it does the job with no extra infrastructure.

JSON columns work fine. You get json_extract, json_each, and json_set. Stashing loose attributes in one column is a fair choice when the schema is truly sparse.

Plan for eventual consistency on read replicas. A write you just made may not show up in another region for a short window. Code that needs read-your-writes should target the primary. Everywhere else, take the stale window where an old row does no harm.

Pricing, limits, and Time Travel

The free tier can host a real project, though several of the limits bite earlier than you would expect.

LimitWorkers FreeWorkers Paid ($5/month)
Row reads5 million per day25 billion per month included, then $0.001 per million
Row writes100,000 per day50 million per month included, then $1.00 per million
Storage5 GB totalFirst 5 GB included, then $0.75 per GB-month
Databases per account1050,000
Max database size500 MB10 GB
Time Travel retention7 days30 days
Queries per Worker invocation501,000

A “row read” counts the rows SQLite scans, not the rows sent back to the client. Run SELECT * FROM posts WHERE author = 'alice' on a 5,000-row table with no index on author. That burns 5,000 row reads, even if Alice wrote three posts. Indexes decide whether you sit inside the free tier or blow through it before lunch.

The hard limits show up in practice too. A row tops out at 2 MB, which is plenty for normal columns but rules out video clips. SQL statements top out at 100 KB. One query can bind at most 100 parameters, which bites when you write an IN (?, ?, ?, ...) with a long list. Reach for a temp table or chunk the query. Queries also time out at 30 seconds, though a query running that long is almost certainly doing something wrong.

Time Travel gives you point-in-time recovery for free. One command rewinds a database to any minute inside the retention window. That makes a risky migration on a live database far less scary than the same move on an unbacked Postgres box. You never take the system down to restore a backup. The rewind happens in place.

When D1 is the right choice and when it isn’t

D1 fits content APIs, blog backends, side projects, internal tools, marketing sites with live data, and most read-heavy work that wants edge latency. You get no ops work, scale-to-zero pricing, and a database engine most backend developers already know.

It is the wrong tool in four cases. Write-heavy transactional systems stall on the single-threaded write path. Real-time analytics runs far cheaper on a column store like ClickHouse . Large-scale full-text search belongs on Meilisearch or Typesense . Apps that need strong write consistency across regions need something else.

The main alternatives in 2026 worth comparing against:

  • Turso runs on libSQL, an open fork of SQLite. It offers true multi-region writes plus embedded replicas that sync to a local SQLite file. Its model fits multi-tenant SaaS, where each tenant gets a database.
  • Neon is serverless Postgres with branching, scale-to-zero, and the full Postgres feature set. Pick it when you need stored procedures, complex types, or strong writes across regions.
  • PlanetScale is serverless MySQL with branching, focused on horizontal scale.
  • Supabase layers Postgres with realtime subscriptions, auth, and storage in a single platform.

A note on lock-in. The D1 binding API is Cloudflare-only, but the database under it is plain SQLite. Export the file with wrangler d1 export, open it with the sqlite3 CLI, and move to Turso or your own SQLite service with no schema changes. If you self-host the API instead of running it on Workers, the three runtimes each give it a different speed profile. Only the runtime glue ties you to Cloudflare, and the data comes with you.

Most developers build a small read-heavy API that needs low latency and no ops work. For that project, D1 is the path of least resistance. Ten minutes from a fresh database to a deployed API on your own domain is a fair expectation. For a small project, the monthly bill rounds to zero.