De-Clouding to the Edge: Architectural Migration from Vercel/Neon to Cloudflare Workers & D1

How we decommissioned our centralized US-East Postgres cluster and Node.js serverless tier, cutting cloud compute spend by 87% while eliminating cold starts with Cloudflare Workers, D1, and KV.

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.

-87%
Total Cloud Spend
< 4ms
p95 Worker Init
11ms
p95 Read Latency
$0.00
Data Egress Cost

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.

-87%
Total Cloud Spend
< 4ms
p95 Worker Init
11ms
p95 Read Latency
$0.00
Data Egress Cost

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.