MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Cloudflare D1: Serverless SQLite at the Edge

Use Cloudflare D1: serverless database, edge deployment, and Workers integration.

CloudflareD1SQLiteServerless

By MinhVo

Introduction

Serverless computing has transformed how developers build and deploy applications, but the database layer has historically been the bottleneck. Traditional databases require persistent connections, connection pooling, and careful capacity planning—all of which conflict with the ephemeral nature of serverless functions. Cloudflare D1 addresses this by bringing SQLite to the edge as a fully managed serverless database.

D1 is Cloudflare's serverless database built on SQLite, designed to run at the edge alongside Cloudflare Workers. It offers sub-millisecond read latency for data stored at the edge, automatic scaling, and a familiar SQL interface that works with standard SQLite tooling. Unlike traditional databases that run in a single region, D1 replicates data across Cloudflare's global network of over 300 data centers, bringing your data closer to your users.

Edge computing architecture

The combination of D1 and Workers creates a powerful platform for building globally distributed applications with zero cold starts, no connection pooling headaches, and pay-per-query pricing. This guide covers everything you need to know to build production applications with D1, from basic setup to advanced patterns like read replicas and data migration.

Understanding D1: Core Concepts

D1 is built on SQLite, the most widely deployed database engine in the world. SQLite's simplicity—everything in a single file, zero configuration, and a rich SQL dialect—makes it an excellent foundation for a serverless database. D1 extends SQLite with Cloudflare's global replication infrastructure, ensuring that data is available at the edge with minimal latency.

The serverless model means you don't manage servers, connections, or capacity. D1 automatically handles replication, backups, and scaling. You pay for the number of reads and writes, not for idle server time. This makes D1 cost-effective for applications with variable traffic patterns.

Serverless database architecture

D1 uses a primary-replica architecture. Writes go to the primary database and are replicated to read replicas across Cloudflare's network. Reads can be served from the nearest replica, providing sub-millisecond latency for most queries. The replication is eventually consistent—there may be a brief delay between a write and when it's visible on read replicas—but this is typically under 100ms.

The integration with Workers is seamless. Workers can access D1 through a binding, which provides type-safe access to the database without managing connection strings or authentication tokens. The binding handles connection management, request routing, and error handling automatically.

Architecture and Design Patterns

D1 and Workers Integration

The primary pattern for using D1 is through Worker bindings. A binding creates a direct connection between your Worker and a D1 database, providing methods for executing queries, prepared statements, and batch operations.

// wrangler.toml
// [[d1_databases]]
// binding = "DB"
// database_name = "my-app-db"
// database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
 
// worker.ts
interface Env {
  DB: D1Database
}
 
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url)
    
    if (url.pathname === '/api/users') {
      const users = await env.DB.prepare(
        'SELECT id, name, email FROM users ORDER BY created_at DESC LIMIT 50'
      ).all()
      
      return Response.json(users.results)
    }
    
    if (url.pathname === '/api/users' && request.method === 'POST') {
      const body = await request.json()
      const result = await env.DB.prepare(
        'INSERT INTO users (name, email) VALUES (?, ?) RETURNING id, name, email'
      ).bind(body.name, body.email).first()
      
      return Response.json(result, { status: 201 })
    }
    
    return new Response('Not Found', { status: 404 })
  }
}

Data Modeling for Edge

D1 works best with denormalized data models that minimize the number of queries needed per request. Since D1 is SQLite-based, it supports JSON columns, full-text search (FTS5), and complex queries, but the edge latency model favors simple, fast queries.

// Schema design for D1
async function setupSchema(env: Env) {
  await env.DB.exec(`
    CREATE TABLE IF NOT EXISTS posts (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      title TEXT NOT NULL,
      slug TEXT UNIQUE NOT NULL,
      content TEXT NOT NULL,
      author_id INTEGER NOT NULL,
      tags TEXT, -- JSON array stored as text
      published_at TEXT,
      created_at TEXT DEFAULT (datetime('now')),
      updated_at TEXT DEFAULT (datetime('now'))
    );
 
    CREATE INDEX idx_posts_slug ON posts(slug);
    CREATE INDEX idx_posts_author ON posts(author_id);
    CREATE INDEX idx_posts_published ON posts(published_at DESC);
 
    -- Full-text search index
    CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
      title, content, content=posts, content_rowid=id
    );
 
    -- Triggers to keep FTS index in sync
    CREATE TRIGGER posts_ai AFTER INSERT ON posts BEGIN
      INSERT INTO posts_fts(rowid, title, content) VALUES (new.id, new.title, new.content);
    END;
  `)
}

Batch Operations

D1 supports batch operations that execute multiple statements in a single request, reducing round trips and ensuring atomicity.

// Batch operations for data seeding or migrations
async function seedData(env: Env) {
  const batch = [
    env.DB.prepare('INSERT INTO categories (name, slug) VALUES (?, ?)').bind('Technology', 'technology'),
    env.DB.prepare('INSERT INTO categories (name, slug) VALUES (?, ?)').bind('Science', 'science'),
    env.DB.prepare('INSERT INTO categories (name, slug) VALUES (?, ?)').bind('Design', 'design'),
  ]
  
  const results = await env.DB.batch(batch)
  return results
}

Database replication

Step-by-Step Implementation

Setting Up D1 with Wrangler

# Install Wrangler CLI
npm install -g wrangler
 
# Create a new D1 database
wrangler d1 create my-app-db
 
# Run a migration file
wrangler d1 execute my-app-db --file=./migrations/0001_create_users.sql
 
# Execute a query against the database
wrangler d1 execute my-app-db --command "SELECT * FROM users LIMIT 10"

Building a Full API with D1

// src/index.ts
interface Env {
  DB: D1Database
}
 
interface User {
  id: number
  name: string
  email: string
  created_at: string
}
 
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const router = new Router()
    
    router.get('/api/users', async () => {
      const { results } = await env.DB.prepare(
        'SELECT id, name, email, created_at FROM users ORDER BY created_at DESC'
      ).all<User>()
      return Response.json(results)
    })
    
    router.get('/api/users/:id', async (params) => {
      const user = await env.DB.prepare(
        'SELECT id, name, email, created_at FROM users WHERE id = ?'
      ).bind(params.id).first<User>()
      
      if (!user) return new Response('Not Found', { status: 404 })
      return Response.json(user)
    })
    
    router.post('/api/users', async (params, request) => {
      const body = await request.json() as { name: string; email: string }
      
      const existing = await env.DB.prepare(
        'SELECT id FROM users WHERE email = ?'
      ).bind(body.email).first()
      
      if (existing) {
        return Response.json({ error: 'Email already exists' }, { status: 409 })
      }
      
      const result = await env.DB.prepare(
        'INSERT INTO users (name, email) VALUES (?, ?) RETURNING id, name, email, created_at'
      ).bind(body.name, body.email).first<User>()
      
      return Response.json(result, { status: 201 })
    })
    
    return router.handle(request)
  }
}

Full-Text Search with D1

// Search implementation using FTS5
async function searchPosts(env: Env, query: string, page: number = 1) {
  const offset = (page - 1) * 20
  
  const { results } = await env.DB.prepare(`
    SELECT p.id, p.title, p.slug, p.published_at,
           snippet(posts_fts, 1, '<mark>', '</mark>', '...', 32) as excerpt,
           rank
    FROM posts_fts
    JOIN posts p ON posts_fts.rowid = p.id
    WHERE posts_fts MATCH ?
    ORDER BY rank
    LIMIT 20 OFFSET ?
  `).bind(query, offset).all()
  
  return results
}

Connecting D1 to a Next.js App

// app/api/posts/route.ts (Next.js App Router)
import { getCloudflareContext } from '@cloudflare/next-on-pages'
 
export async function GET() {
  const { env } = getCloudflareContext()
  
  const { results } = await env.DB.prepare(
    'SELECT * FROM posts WHERE published_at IS NOT NULL ORDER BY published_at DESC LIMIT 20'
  ).all()
  
  return Response.json(results)
}

Real-World Use Cases

Blog and Content Platform

D1 is ideal for blogs and content platforms where read traffic significantly outweighs write traffic. The edge-replicated read replicas serve content with sub-millisecond latency, and the FTS5 extension provides full-text search without an external search service.

API Configuration Store

Store API configurations, feature flags, and rate limit rules in D1 for fast access at the edge. Workers can read configuration on every request without adding latency, enabling real-time configuration changes without redeployment.

Session and User Data

Store session data and user preferences in D1 for fast access by Workers handling authentication and personalization. The edge latency ensures that session validation doesn't add noticeable delay to request processing.

E-Commerce Product Catalog

Store product catalogs, inventory levels, and pricing in D1 for fast product lookups at the edge. Use batch operations to update inventory during checkout and read replicas to serve product pages with minimal latency.

Best Practices for Production

  1. Use prepared statements: Always use parameterized queries with .bind() instead of string interpolation. This prevents SQL injection and enables query plan caching.

  2. Design for single-region writes: D1's primary is in a single region. Design your write patterns to minimize cross-region write latency by batching writes or using eventually consistent patterns.

  3. Leverage FTS5 for search: D1 includes SQLite's FTS5 extension for full-text search. Use it instead of an external search service for text search workloads that fit within D1's capabilities.

  4. Use batch operations: Group related operations into batches to reduce round trips. D1's batch API executes multiple statements atomically.

  5. Implement proper error handling: D1 may return errors for constraint violations, syntax errors, or resource limits. Handle these gracefully in your Workers.

  6. Monitor query performance: Use D1's built-in query analytics to identify slow queries and optimize them with appropriate indexes.

  7. Use migrations for schema changes: Manage schema changes through migration files executed with Wrangler. This ensures consistent schema across environments.

  8. Cache frequently accessed data: Use Workers KV or Cache API alongside D1 for frequently accessed, rarely changing data to reduce D1 read costs.

Common Pitfalls and Solutions

PitfallImpactSolution
Cross-region write latencySlow writes for distant usersBatch writes, use eventually consistent patterns
Missing indexes on query columnsFull table scans, slow queriesCreate indexes for frequently queried columns
String interpolation in queriesSQL injection vulnerabilitiesAlways use .bind() for parameterized queries
Ignoring replication lagStale reads after writesRead from primary after writes, or accept eventual consistency
Not using batch operationsExcessive round tripsGroup related operations into batches
Storing large blobs in D1Slow queries, storage costsUse R2 for file storage, store references in D1
Not monitoring D1 limitsRate limiting, errorsMonitor read/write counts and storage usage
Using D1 for high-write workloadsWrite contention, performance issuesConsider other databases for write-heavy patterns

Performance Optimization

D1 performance depends on query design, index usage, and data model. Use EXPLAIN QUERY PLAN to analyze query performance and create indexes for frequently accessed columns.

// Analyze query performance
async function analyzeQuery(env: Env) {
  const plan = await env.DB.prepare(
    'EXPLAIN QUERY PLAN SELECT * FROM posts WHERE author_id = ? ORDER BY published_at DESC'
  ).bind(1).all()
  
  console.log('Query plan:', plan.results)
}
 
// Use covering indexes to avoid table lookups
async function createOptimalIndexes(env: Env) {
  await env.DB.exec(`
    CREATE INDEX idx_posts_author_published 
    ON posts(author_id, published_at DESC) 
    INCLUDE (title, slug);
  `)
}

Use the RETURNING clause to avoid additional SELECT queries after INSERT or UPDATE operations. This reduces round trips and improves write operation latency.

Comparison with Alternatives

FeatureCloudflare D1PlanetScale NeonTurso (libSQL)Supabase
EngineSQLiteMySQLSQLite (libSQL)PostgreSQL
Edge ReplicationYes (300+ locations)NoYesNo
ServerlessYesYesYesPartial
Connection ModelWorker bindingHTTP/WebSocketHTTPConnection pool
Full-Text SearchFTS5ExternalFTS5Built-in
Free Tier5M reads/day, 100K writes/day5 GB storage500M reads/month500 MB
PricingPer read/writePer row read/writtenPer read/writePer compute hour

Advanced Patterns

D1 with Hyperdrive

For applications that need to access D1 from outside Cloudflare Workers, use Hyperdrive to provide connection pooling and caching.

Read-After-Write Consistency

Implement read-after-write consistency by reading from the primary after a write operation.

async function createAndFetch(env: Env, name: string, email: string) {
  // Write to primary
  const created = await env.DB.prepare(
    'INSERT INTO users (name, email) VALUES (?, ?) RETURNING id, name, email'
  ).bind(name, email).first()
  
  // Read immediately (may hit primary for consistency)
  const user = await env.DB.prepare(
    'SELECT id, name, email, created_at FROM users WHERE id = ?'
  ).bind(created.id).first()
  
  return user
}

Data Export and Backup

Use Wrangler to export D1 data for backup or migration to other databases.

# Export data as SQL
wrangler d1 export my-app-db --output=./backup.sql
 
# Export specific tables
wrangler d1 export my-app-db --table=users --output=./users.sql

Testing Strategies

Test D1 Workers locally using Wrangler's local development mode, which creates an in-memory SQLite database that mirrors D1's behavior.

// test/worker.test.ts
import { unstable_dev } from 'wrangler'
 
describe('D1 Worker', () => {
  let worker: any
  
  beforeAll(async () => {
    worker = await unstable_dev('src/index.ts', {
      experimental: { disableExperimentalWarning: true }
    })
  })
  
  afterAll(async () => {
    await worker.stop()
  })
  
  test('should create and retrieve a user', async () => {
    const createRes = await worker.fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify({ name: 'Test User', email: 'test@example.com' }),
      headers: { 'Content-Type': 'application/json' }
    })
    expect(createRes.status).toBe(201)
    const user = await createRes.json()
    expect(user.name).toBe('Test User')
    
    const getRes = await worker.fetch(`/api/users/${user.id}`)
    expect(getRes.status).toBe(200)
  })
})

Future Outlook

Cloudflare is actively developing D1 with features like point-in-time recovery, global read replicas with stronger consistency guarantees, and improved query performance. The platform is also exploring integration with AI workloads, allowing vector search and embeddings storage directly in D1.

The serverless database space is growing rapidly, with D1 positioned as the edge-native option. As Cloudflare's network continues to expand, D1's latency advantages will increase, making it an increasingly compelling choice for globally distributed applications.

D1 Database Limits and Scaling

Cloudflare D1 has specific limits that affect application design. Each database has a maximum size of 10 GB, individual queries have a 10-second timeout, and batch operations are limited to 100 statements per transaction. For applications that outgrow these limits, consider sharding data across multiple D1 databases or using D1 for read-heavy workloads while routing write-heavy workloads to a traditional database. Monitor query performance using D1's built-in analytics dashboard and optimize slow queries by adding appropriate indexes.

D1 Data Modeling Strategies

Design D1 database schemas with the edge computing paradigm in mind. Prefer denormalized data structures that reduce the number of queries needed per request, since each query incurs round-trip latency to the storage backend. Use composite indexes that cover common query patterns to minimize full table scans. Implement soft deletes using a deleted_at column instead of actually deleting rows, which simplifies data recovery and audit trails. Use SQLite-specific features like JSON functions for semi-structured data that doesn't fit neatly into relational columns.

Conclusion

Cloudflare D1 brings SQLite to the edge as a fully managed serverless database. Its integration with Workers, global replication, and pay-per-query pricing make it an excellent choice for applications that need fast, globally distributed data access without the operational overhead of traditional databases.

Key takeaways:

  1. D1 excels at read-heavy workloads where data is read close to where it's written, leveraging edge replication for sub-millisecond latency.
  2. Use prepared statements and batch operations for secure, efficient database access.
  3. Design for the edge model with denormalized data, covering indexes, and eventually consistent reads.
  4. Start with D1 for new projects that need a simple, globally distributed database without connection pooling complexity.

Start by creating a D1 database with Wrangler, building a simple CRUD API with Workers, and deploying it to Cloudflare's edge. Refer to the Cloudflare D1 documentation for detailed guides and the Workers documentation for platform integration.