When building greenfield systems, the “modern serverless stack”—typically Next.js on Vercel backed by serverless PostgreSQL like Neon or Supabase—offers rapid initial velocity. However, as transactional throughput scales and traffic diversifies globally, this topology exhibits systemic architectural penalties: persistent compute cold starts, database connection exhaustion, latency tax from cross-continent database round-trips, and predatory egress pricing.
Over Q2, we re-architected our core transactional API layer away from Vercel and a centralized Neon PostgreSQL instance (us-east-1), migrating directly to Cloudflare Workers, D1 (distributed SQLite), and Workers KV.
The Legacy Architecture: The Latency and Connection Tax
Our legacy stack relied on Node.js lambdas deployed across Vercel regions, connecting back to a centralized Neon Postgres compute node in AWS us-east-1.
[ Global Client ]
When building greenfield systems, the “modern serverless stack”—typically Next.js on Vercel backed by serverless PostgreSQL like Neon or Supabase—offers rapid initial velocity. However, as transactional throughput scales and traffic diversifies globally, this topology exhibits systemic architectural penalties: persistent compute cold starts, database connection exhaustion, latency tax from cross-continent database round-trips, and predatory egress pricing.
Over Q2, we re-architected our core transactional API layer away from Vercel and a centralized Neon PostgreSQL instance (us-east-1), migrating directly to Cloudflare Workers, D1 (distributed SQLite), and Workers KV.
The Legacy Architecture: The Latency and Connection Tax
Our legacy stack relied on Node.js lambdas deployed across Vercel regions, connecting back to a centralized Neon Postgres compute node in AWS us-east-1.
[Global Client] │ ▼ (Edge Anycast DNS) [Vercel Edge Proxy] │ ▼ (Routed to Lambda Region: e.g., iad1 or fra1) [Node.js Serverless Container] ── (Cold Start: 350ms - 1200ms) │ │ (TCP / WebSocket Pooler: @neondatabase/serverless) ▼ [Neon Connection Pooler (PgBouncer)] │ ▼ [Postgres Compute (us-east-1)] ── (Idle Suspend Wakeup: 1.5s - 3s)
Architectural Friction Points
The Dual Cold Start Cascade: When Neon scaled compute to zero to save idle costs, an incoming cold request paid two compounding penalties: the Vercel Node container boot (~400ms) plus the Neon compute resume and TLS handshake (~1500ms).
Connection Starvation & Pooling Tax: Node serverless containers cannot maintain stateful TCP pools across invocations. While Neon provides an HTTP/WebSocket proxy driver, every query payload incurs serialization overhead and HTTP round-trip latency over the public internet if the worker runs outside AWS us-east-1.
Egress Asymmetry: Transferring analytics payloads and read-heavy snapshots from AWS/Neon out to multi-region clients racked up significant egress bills.
Target Topology: Zero-Egress Global Edge
Instead of routing traffic from the edge back to a centralized regional database, we inverted the model: compile the execution logic to V8 isolates running directly on Cloudflare’s network, and place durable relational state on Cloudflare D1 with edge read replication.
[Global Client] │ ▼ (< 15ms TLS Termination) [Cloudflare Edge PoP (275+ Cities)] │ ├─► [Workers KV] ── Cached session/auth tokens (< 5ms read) │ ├─► [Cloudflare Worker (V8 Isolate)] ── (Cold Start: 0ms - 5ms) │ │ │ ├─► [D1 Edge Read Replica] (Local read: ~10ms) │ │ │ └─► [D1 Primary Coordinator] (Writes via Raft) ▼ [Response to Client]
Why D1 Over Centralized Postgres?
Cloudflare D1 is built on SQLite, running on top of Durable Objects backed by a distributed consensus engine (Raft).
Isolate Proximity: Queries execute over internal system bindings (env.DB.prepare()) without network hops or TCP/SSL handshakes.
Automatic Read Replication: D1 creates dynamic read replicas globally. A read query in Tokyo executes against local edge storage rather than routing to Virginia.
Cold Starts Eliminated: Workers execute within pre-warmed V8 isolates. Worker startup is deterministic and under 5ms.
Schema & Driver Migration: Postgres to D1
SQLite handles data types differently than PostgreSQL. Postgres features rich native types (JSONB, UUID, TIMESTAMPTZ, ENUM), whereas SQLite operates with five storage classes: NULL, INTEGER, REAL, TEXT, and BLOB.
Type Mapping Strategy
Postgres Data Type
SQLite / D1 Storage Class
Handling Mechanism
UUID
TEXT
Generated at edge via crypto.randomUUID()
TIMESTAMPTZ
INTEGER
Unix epoch timestamps in milliseconds (Date.now())
JSONB
TEXT
JSON.stringify() / JSON.parse() via ORM serializers
BOOLEAN
INTEGER
0 or 1 with strict schema validation
SERIAL / BIGSERIAL
INTEGER PRIMARY KEY AUTOINCREMENT
Native SQLite rowid semantics
Drizzle ORM Schema Translation
We maintained type safety by swapping the Drizzle PostgreSQL dialect for the D1 SQLite dialect:
// schema/tenants.ts (Drizzle D1 Implementation)
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const tenants = sqliteTable('tenants', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
metadata: text('metadata', { mode: 'json' }).$type<Record<string, unknown>>(),
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(1),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date()),
}, (table) => ({
slugIdx: index('idx_tenants_slug').on(table.slug),
}));
Migration Pipeline via Wrangler
We exported the Postgres dataset into sanitized CSV dumps, converted inserts to SQLite-compliant SQL statements, and applied migrations using the native Wrangler CLI:
# 1. Generate empty D1 migration
npx wrangler d1 migrations create prod-db init_schema
# 2. Apply locally for unit and integration testing
npx wrangler d1 migrations apply prod-db --local
# 3. Apply to global production cluster
npx wrangler d1 migrations apply prod-db --remote
High-Throughput Edge Handler Implementation
Below is a production-grade Cloudflare Worker endpoint handling tenant lookups and updates with D1 transaction batching and KV caching:
// src/worker.ts
import { drizzle } from 'drizzle-orm/d1';
import { eq } from 'drizzle-orm';
import { tenants } from './schema/tenants';
export interface Env {
DB: D1Database;
CACHE_KV: KVNamespace;
ENVIRONMENT: 'production' | 'staging' | 'local';
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (request.method === 'GET' && url.pathname.startsWith('/api/tenants/')) {
const slug = url.pathname.split('/').pop();
if (!slug) {
return new Response(JSON.stringify({ error: 'Missing tenant slug' }), { status: 400 });
}
// 1. Fast path: KV cache lookup
const cacheKey = `tenant:${slug}`;
const cached = await env.CACHE_KV.get(cacheKey, 'json');
if (cached) {
return Response.json(cached, {
headers: { 'X-Cache': 'HIT', 'Cache-Control': 'public, max-age=60' },
});
}
// 2. Slow path: Edge D1 read query
const db = drizzle(env.DB);
const result = await db.select().from(tenants).where(eq(tenants.slug, slug)).limit(1);
if (result.length === 0) {
return new Response(JSON.stringify({ error: 'Tenant not found' }), { status: 404 });
}
const tenant = result[0];
// 3. Asynchronously populate KV without blocking the response
ctx.waitUntil(
env.CACHE_KV.put(cacheKey, JSON.stringify(tenant), {
expirationTtl: 300, // 5 minutes
})
);
return Response.json(tenant, {
headers: { 'X-Cache': 'MISS', 'Cache-Control': 'public, max-age=60' },
});
}
return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405 });
},
};
Secret Management: Decoupling from Kubernetes / External Vaults
One common antipattern when leaving centralized platforms is hardcoding secrets inside GitHub Actions or spreading them across multiple UI dashboards.
We decouple credential distribution by maintaining an internal Kubernetes secret store (HashiCorp Vault / External Secrets Operator) and syncing production secrets to Cloudflare through an automated pipeline using Wrangler and fine-grained Cloudflare API Tokens.
[Vault / Kubernetes Secret] │ ▼ (GitOps Trigger / Dispatch) [GitHub Actions Runner (OIDC)] │ ▼ (Scoped Cloudflare API Token) [Wrangler CLI Secret Injection] │ ├─► wrangler secret put AUTH_PRIVATE_KEY └─► wrangler secret put STRIPE_WEBHOOK_SECRET
Automation Script for CI
Instead of manual wrangler secret put interactive prompts in local terminals, we use automated piping directly in CI:
#!/usr/bin/env bash
set -euo pipefail
# Deploy environment secrets securely from CI environment
echo "Syncing production secrets to Cloudflare Worker..."
echo "$PROD_AUTH_PRIVATE_KEY" | npx wrangler secret put AUTH_PRIVATE_KEY --env production
echo "$PROD_STRIPE_WEBHOOK_SECRET" | npx wrangler secret put STRIPE_WEBHOOK_SECRET --env production
echo "Executing canary deployment..."
npx wrangler deploy --env production
Configuration files (wrangler.jsonc) remain clean of non-public parameters, checked into Git with typed resource bindings:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "core-api-service",
"main": "src/worker.ts",
"compatibility_date": "2026-08-15",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "prod-core-db",
"database_id": "9d81d26c-fa74-4b47-8a4e-1209b55280df"
}
],
"kv_namespaces": [
{
"binding": "CACHE_KV",
"id": "c3f8e5c2d3a14e6b98e1b9a2c3d4e5f6"
}
]
}
Architectural Lessons & Trade-Offs
Migrating to the edge is not a zero-cost abstraction. Acknowledging operational trade-offs early prevents catastrophic redesigns downstream:
1. Write Serialization vs. Massive Read Throughput
SQLite achieves extreme simplicity and speed because it permits concurrent readers while serializing writers. If your workload consists of thousands of un-batched concurrent writes per second, D1 will encounter write queue contention. For our workload—which exhibits a 96:4 read-to-write ratio—D1’s read replicas outperformed Postgres by an order of magnitude. For high-velocity append streams, queueing writes via Cloudflare Queues or Vectorize before flushing to D1 is mandatory.
2. Transaction Boundaries
D1 supports atomic transactions, but you cannot hold open a transaction over an indefinite network wait loop. Transactions must be executed as atomic batches via env.DB.batch([…]). This requires structuring mutations deterministically rather than relying on interactive imperative transactions across disparate services.
3. Cost Architecture
By terminating compute on V8 isolates and co-locating data inside Cloudflare’s network, we eliminated:
- Third-party connection poolers ($150–$300/mo).
- Node serverless container run-time overage ($800+/mo).
- Data egress charges between AWS us-east-1 and global consumer nodes.
The resulting footprint runs comfortably on Cloudflare’s Workers Paid tier, yielding an immediate 87% net reduction in recurring cloud infrastructure expenses.