Back to Blog
Feb 4, 20264 min readOnuzulike Anthony

Neon PostgreSQL for Serverless Apps

Branching, autoscaling, connection pooling with Hyperdrive, and the schema setup patterns that make Neon work well with Drizzle ORM on Cloudflare Workers.

InfrastructurePostgreSQLNeonServerlessDrizzle ORM

Neon is PostgreSQL with a compute/storage split — storage persists independently, and compute scales to zero when idle. This makes it practical for serverless workloads where a traditional RDS instance would sit idle for hours between requests.

Why Neon over PlanetScale or Supabase

  • Branching: create a full database branch from any point in time in seconds. Essential for staging environments and zero-downtime migrations.
  • Scale to zero: no charges when idle. PlanetScale has similar pricing; Supabase minimum is $25/month.
  • Neon HTTP driver: works from Cloudflare Workers where TCP connections are prohibited.
  • Standard PostgreSQL: no proprietary syntax. Drizzle, Prisma, and raw SQL all work identically.

Connecting from Cloudflare Workers

Workers run in the edge runtime, which blocks TCP connections. Neon provides an HTTP driver that tunnels SQL over HTTPS:

typescript
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
 
export function getDb(databaseUrl: string) {
  const sql = neon(databaseUrl);
  return drizzle(sql, { schema });
}

For production, wrap this with Cloudflare Hyperdrive:

typescript
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
 
export function getDb(env: Env) {
  // Hyperdrive wraps the connection URL with connection pooling + caching
  const sql = neon(env.HYPERDRIVE.connectionString);
  return drizzle(sql, { schema });
}

Hyperdrive

Hyperdrive is Cloudflare's connection pool and query cache proxy. Without it, every Worker invocation opens a new TCP connection to Neon's HTTP endpoint, adding ~80-120ms latency. Hyperdrive pools connections and can cache SELECT results at the edge.

toml
# wrangler.toml
[[hyperdrive]]
binding = "HYPERDRIVE"
id = "your-hyperdrive-config-id"
bash
# Create Hyperdrive configuration
wrangler hyperdrive create nodwatch-db \
  --connection-string "postgresql://user:pass@ep-xxx.neon.tech/neondb?sslmode=require"

Hyperdrive is transparent — you use the same Drizzle API. It just intercepts the connection and pools it.

Schema with Drizzle ORM

typescript
// src/db/schema.ts
import { pgTable, text, integer, timestamp, boolean, serial } from "drizzle-orm/pg-core";
 
export const users = pgTable("users", {
  id: text("id").primaryKey(),
  email: text("email").notNull().unique(),
  name: text("name"),
  createdAt: timestamp("created_at").defaultNow(),
  updatedAt: timestamp("updated_at").defaultNow(),
});
 
export const watchHistory = pgTable("watch_history", {
  id: serial("id").primaryKey(),
  userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
  contentId: integer("content_id").notNull(),
  contentType: text("content_type").notNull(), // 'movie' | 'tv' | 'anime'
  episodeId: integer("episode_id"),
  progress: integer("progress").default(0),   // seconds watched
  duration: integer("duration"),
  completed: boolean("completed").default(false),
  watchedAt: timestamp("watched_at").defaultNow(),
  updatedAt: timestamp("updated_at").defaultNow(),
});
 
export const ratings = pgTable("ratings", {
  id: serial("id").primaryKey(),
  userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
  contentId: integer("content_id").notNull(),
  contentType: text("content_type").notNull(),
  rating: integer("rating").notNull(), // 1-10
  createdAt: timestamp("created_at").defaultNow(),
});

Migrations

Drizzle generates migration SQL files from schema changes:

bash
# Generate migration
npx drizzle-kit generate:pg --schema=src/db/schema.ts --out=migrations
 
# Apply to Neon
npx drizzle-kit push:pg

Neon branching is invaluable here: create a branch, run the migration against it, verify, then merge to the main branch. No production downtime, no fear of irreversible changes.

bash
# Create a migration branch in Neon console or CLI
neonctl branch create --name migration/add-ratings
 
# Apply migration to branch first
DATABASE_URL="postgresql://...branch-url..." npx drizzle-kit push:pg
 
# Test, then merge branch
neonctl branch merge migration/add-ratings --project-id your-project-id

Common Query Patterns with Drizzle

typescript
// Get user watch history with content details
const history = await db
  .select()
  .from(watchHistory)
  .where(eq(watchHistory.userId, userId))
  .orderBy(desc(watchHistory.watchedAt))
  .limit(50);
 
// Upsert watch progress
await db
  .insert(watchHistory)
  .values({ userId, contentId, contentType, progress, duration })
  .onConflictDoUpdate({
    target: [watchHistory.userId, watchHistory.contentId, watchHistory.contentType],
    set: { progress, updatedAt: new Date() },
  });
 
// Get ratings for recommendation scoring
const userRatings = await db
  .select({ contentId: ratings.contentId, rating: ratings.rating })
  .from(ratings)
  .where(and(eq(ratings.userId, userId), gte(ratings.rating, 7)));

Connection Limits

Neon free tier allows 100 concurrent connections. Cloudflare Workers can spin up thousands of instances simultaneously. Without Hyperdrive, this causes connection exhaustion. Always use Hyperdrive in production — it pools connections and your Workers share a small pool rather than each opening their own.

Local Development

bash
# .env.local
DATABASE_URL="postgresql://user:pass@ep-xxx.us-east-2.neon.tech/neondb?sslmode=require"

For local development without Hyperdrive, use the Neon HTTP driver directly with the DATABASE_URL from .env.local. The code path is identical; just the URL changes.