◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 27 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 27 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-06-15Advanced

Running a Neon + Drizzle Backend for Your Rork App in Production — Notes on Edge Connections, Zero-Downtime Migrations, and Type-Safe Queries

After wiring Neon Serverless Postgres and Drizzle ORM into a Rork app's backend, the friction shows up in production. These are implementation notes on choosing an edge connection model, migrating without locking tables, and designing type-safe queries that don't balloon into N+1.

rork60neondrizzle-ormcloudflare-workerspostgresserverlessbackend-operations

✦ Premium Article

I built a Rork app's backend on Neon and Drizzle, and locally it felt great. A few days into production, one screen would occasionally spike in latency. The cause was mundane: from the edge I was sending each query as its own HTTP round trip, and that one screen fired several small queries in a row.

Local Postgres lives on the same machine, so the round trips are invisible. Move to the edge and the number of times you touch the database in a single request becomes your felt speed. There's plenty written about getting Neon and Drizzle to the "first working query." What matters in production is the decisions that come after. As an indie developer running several apps at once, the "after it works" handling is exactly where my time went. Here are the places I actually had to revisit.

Decide the connection model first — neon-http or the WebSocket Pool

@neondatabase/serverless gives you two doors: the HTTP driver returned by neon(), and the WebSocket driver built on Pool. Most tutorials only show the first, but in production this is the decision to make up front.

The HTTP driver sends each query as a single fetch. It fits edge runtimes like Cloudflare Workers, where you can't hold a connection open, and it shrugs off cold starts. The trade-off: it can't run interactive transactions that group several statements into one atomic unit.

// src/db/http.ts — the path for single, standalone queries
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import * as schema from "./schema";
 
export function createHttpDb(databaseUrl: string) {
  const sql = neon(databaseUrl);
  return drizzle(sql, { schema });
}

When you need a Stripe payment confirmation and a purchase-history insert to "both succeed or both fail," you open a transaction over the WebSocket Pool. Half-finished writes around payments are expensive to clean up, so this is the one place I never compromise on a transaction.

// src/db/pool.ts — the path for code that needs a transaction
import { drizzle } from "drizzle-orm/neon-serverless";
import { Pool } from "@neondatabase/serverless";
import * as schema from "./schema";
 
export function createPoolDb(databaseUrl: string) {
  const pool = new Pool({ connectionString: databaseUrl });
  return drizzle(pool, { schema });
}

In my case I recommend splitting into two paths: reads and single writes over HTTP, and only the endpoints that must keep several rows consistent over the Pool. Route everything through the Pool and the WebSocket handshake makes the first call slower; route everything through HTTP and you end up reimplementing consistency outside the ORM. The deciding question is: does this endpoint need multiple writes to land as a single result? If not, HTTP stays lighter and faster — that's what the measurements showed.

Geography matters too. If the Neon region and your main users are far apart, every HTTP round trip carries a fixed delay. If your users are concentrated in one region, put Neon near them. A fast edge doesn't shorten the physical distance to the database.

Count how many times one request touches the database

The spiky latency almost always traces back to queries per screen. Because Drizzle's query builder reads so plainly, it's tempting to fetch a list and then pull related rows inside a loop. That's N+1.

// Anti-pattern: fetch the author N times for N posts
const posts = await db.select().from(postsTable).limit(20);
for (const p of posts) {
  // this one line becomes 20 round trips
  p.author = await db.select().from(users).where(eq(users.id, p.userId));
}

At the edge, those 20 round trips turn into hundreds of milliseconds of felt delay. In my measurements, collapsing that same screen into a single join made responses roughly 3x faster. Drizzle's relational queries let you pull the joins you need in one shot.

// Better: declare the relation and fetch it once
const posts = await db.query.postsTable.findMany({
  limit: 20,
  orderBy: (p, { desc }) => [desc(p.createdAt)],
  with: {
    author: {
      columns: { id: true, displayName: true, avatarUrl: true },
    },
  },
});

Narrowing columns here isn't cosmetic. Pulling the full body and internal flags you never render adds up in transfer size and serialization cost. Edge functions cap CPU time, so trimming returned columns to what you actually use pays off.

Aggregates are the same. For a value where you only want the number — a post count, say — don't fetch all the rows and read length; let the database count with $count.

const postCount = await db.$count(postsTable, eq(postsTable.userId, userId));

What helped most in practice was making query counts visible during development. Turn on Drizzle's logger and print queries-per-request, and every time you build a screen you can eyeball "did this list quietly become N+1?" That's far cheaper than discovering it in production.

const db = drizzle(sql, { schema, logger: env.ENVIRONMENT !== "production" });
✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦A concrete rule for choosing between neon-http and the WebSocket Pool, based on transaction needs and geographic latency
✦An expand / contract procedure for changing columns without locking live tables, and how to land it in Drizzle Kit
✦How to fetch related data without N+1, and where to place query-count observability in a scale-to-zero environment
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-10-23
Failed to resolve plugin for module: Check the Spelling, the Install, the File, Then the Contents
You add one line to plugins in app.json and Expo stops with PluginError: Failed to resolve plugin for module. I reproduced the causes behind that one message in a blank Expo SDK 58 project and put them in the order I now check them, using a single command.
⬡ Dev Tools2026-05-20
Fixing 0x8badf00d Watchdog Kills That Wipe Out Rork Apps at Launch
Your Rork iOS app dies right after launch on real devices, but never on your bench. Crashlytics shows exception code 0x8badf00d. Here is how to read the exact watchdog budget out of the crash log and rearrange your launch path so it stops.
⬡ Dev Tools2026-04-24
When Your Rork App Binary Tops 150MB: 5 Causes to Isolate and a Step-by-Step Slim-Down Plan
My Rork-built app archived at 172MB and App Store Connect stopped me cold. Here is how I broke the bloat into 5 measurable causes and got it down to 69MB — including the three cuts that backfired.