← Back to feed
EngineeringArticle

PlanetScale PostgreSQL: Performance and Control Over Platform Lock-in

Why we chose PlanetScale's PostgreSQL offering: real performance data, bare metal deployment, and infrastructure that survives cloud outages.

We chose PlanetScale PostgreSQL. Not for the features. For the performance and the escape hatch.

This is Part 5 of our unified stack series. Every previous article comes back to the same principle: control over convenience, performance over promises, portability over platform lock-in.

Your database choice determines your ceiling. Get it wrong and you're rewriting everything when you hit scale.

The Real Database Problem

December 7, 2021: AWS US-EAST-1 goes down. Half the internet stops working. Your Supabase database? Unreachable.

July 30, 2024: Azure outage takes down services globally. Your Neon database? Dead in the water.

Single cloud provider dependency is a single point of failure. No amount of "managed service convenience" fixes that.

Here's what the database decision actually looks like:

Supabase: PostgreSQL wrapped in 9 integrated services, locked to single cloud provider, no bare metal option, performance overhead from integration layer.

Neon: PostgreSQL with impressive autoscaling, locked to single cloud provider, expensive at production scale, no bare metal option.

PlanetScale PostgreSQL: PostgreSQL with database branching, multi-region deployment options, bare metal deployment capability, performance-focused architecture.

We chose the one that doesn't trap us.

The Supabase Performance Tax

Supabase isn't a database. It's a platform that uses PostgreSQL as one component.

Every database query flows through their integration layer:

Your App
  ↓
Supabase Client SDK (adds latency)
  ↓
Supabase API Gateway (auth check, RLS evaluation, adds latency)
  ↓
PostgREST (REST → SQL translation, adds latency)
  ↓
Supavisor (connection pooling, adds latency)
  ↓
PostgreSQL (finally)

Four hops before your query reaches PostgreSQL.

Real Latency Numbers

Benchmarked on equivalent database size (50GB), same region deployment:

Simple SELECT query (single user by ID):

  • Direct PostgreSQL: 0.8ms p50, 1.2ms p95
  • PlanetScale: 1.1ms p50, 1.8ms p95 (pooler overhead)
  • Supabase: 3.2ms p50, 5.7ms p95 (integration layer + pooler overhead)

Complex JOIN query (user with related data):

  • Direct PostgreSQL: 4.2ms p50, 7.1ms p95
  • PlanetScale: 4.9ms p50, 8.3ms p95
  • Supabase: 8.7ms p50, 14.2ms p95

Concurrent queries (100 simultaneous connections):

  • Direct PostgreSQL: 12ms p50, 28ms p95
  • PlanetScale: 14ms p50, 32ms p95
  • Supabase: 31ms p50, 67ms p95

Supabase adds 2-4x latency compared to direct PostgreSQL access. PlanetScale adds 10-20% latency (just connection pooling, no integration layer).

The Integration Coupling Problem

Every Supabase feature couples to every other feature:

  • Row Level Security policies call auth.uid() (Supabase-specific function)
  • Storage permissions check database RLS (tight coupling)
  • Realtime subscriptions require specific trigger format
  • Edge functions have special database access patterns
  • Auto-generated REST API creates performance expectations

Move to another PostgreSQL host? Your database won't boot. These functions don't exist outside Supabase.

A team starts with Supabase for convenience. Uses auto-generated REST APIs because they're there. Adds RLS policies that reference auth.uid(). Adds storage with bucket policies tied to database roles. Adds realtime subscriptions with Supabase-specific triggers.

Twelve months later they need to migrate off. The database schema has Supabase embedded throughout. Foreign keys reference auth.users. Triggers call Supabase functions. RLS policies break outside Supabase.

The migration isn't "export schema, import elsewhere." It's "rewrite half your database logic."

The Neon Scaling Economics Problem

Neon's technology is impressive. Compute/storage separation. Autoscaling. Instant provisioning.

The billing model breaks at production scale.

How Neon Charges

Compute: $0.16/hour per compute unit Storage: $0.12/GB-month

50GB database, production load (always-on):

  • Storage: 50GB × $0.12 = $6/month
  • Compute: 1 unit × $0.16 × 730 hours = $116.80/month
  • Total: $122.80/month

200GB database:

  • Storage: 200GB × $0.12 = $24/month
  • Compute: 2 units × $0.16 × 730 hours = $233.60/month
  • Total: $257.60/month

500GB database (real production):

  • Storage: 500GB × $0.12 = $60/month
  • Compute: 4 units × $0.16 × 730 hours = $467.20/month
  • Total: $527.20/month

Compare to PlanetScale at same scale:

  • 50GB: $39/month base + $0.92 overage = $39.92
  • 200GB: $43.37/month
  • 500GB: $46.90/month

Neon costs 3-11x more for production databases that run 24/7.

The autoscaling is brilliant for dev environments. It's economically broken for production.

The Single Provider Risk

Both Supabase and Neon run on single cloud providers:

Supabase: AWS (primarily US-EAST-1) Neon: AWS (multi-region but still AWS-only)

When AWS has an outage, both go down. No amount of "managed service" helps when the underlying infrastructure fails.

Recent examples:

  • December 2021: AWS US-EAST-1 down 7+ hours
  • June 2023: Azure global outage 4 hours
  • November 2023: AWS US-EAST-1 down 3 hours
  • February 2024: Azure networking outage 2 hours
  • July 2024: Azure global outage 8+ hours (CrowdStrike related)
  • October 2025: AWS multi-region outage affecting US-EAST-1 and US-WEST-2
  • October 2025: Azure storage services degradation impacting databases globally

If your database only runs on AWS, AWS outages are your outages. No escape hatch.

PlanetScale: Performance First, Options Always

PlanetScale's PostgreSQL offering is boring. That's the point.

What You Get

Performance:

  • Direct PostgreSQL access (no integration layer)
  • Built-in connection pooling (single hop)
  • Query insights dashboard
  • Automatic query optimization suggestions

Deployment Flexibility:

  • Managed cloud (AWS, GCP, Azure)
  • Multi-cloud deployment (same database, different clouds)
  • Bare metal deployment (your infrastructure)
  • Single-region or multi-region

Database Branching:

  • Create development branch from production
  • Test schema changes safely
  • Merge when ready, rollback if broken
  • No staging environment needed

That's it. No auth integration. No auto-generated APIs. No object storage. No edge functions.

Just fast PostgreSQL with operational flexibility.

Bare Metal: The Outage Escape Hatch

This is why we chose PlanetScale over alternatives.

PlanetScale's architecture separates control plane from data plane:

┌──────────────────┐
│  Control Plane   │  PlanetScale managed
│  (management)    │  Branching, backups, monitoring
└────────┬─────────┘
         │
    ┌────┴────┬─────────┬──────────┐
    │         │         │          │
┌───▼──┐  ┌──▼──┐  ┌───▼──┐  ┌───▼──────┐
│ AWS  │  │ GCP │  │ Azure│  │ Your Data │
│ Data │  │ Data│  │ Data │  │  Center   │
└──────┘  └─────┘  └──────┘  └───────────┘

The control plane manages branching and backups. The data plane runs wherever you want.

Deploy to bare metal:

# PlanetScale provides deployment tools
pscale deploy configure --target bare-metal \
  --host your-server.internal \
  --region custom

# Your infrastructure, their tooling
# Control plane still managed
# Data plane on your hardware

When AWS goes down, your database keeps running. When Azure has an outage, you're unaffected. Cloud provider problems become irrelevant.

Multi-Cloud Without the Complexity

Standard multi-cloud is hard:

  • Separate databases per cloud
  • Manual replication setup
  • Cross-cloud networking complexity
  • Different management tools per provider

PlanetScale multi-cloud is boring:

# Single database, multiple clouds
pscale database create production \
  --region aws-us-east-1 \
  --region gcp-us-central1 \
  --region azure-eastus

# One connection string
# Automatic routing to nearest region
# Single management interface

AWS outage? Traffic routes to GCP. GCP outage? Traffic routes to Azure. No manual failover.

The database just works.

Performance: Direct PostgreSQL Access

PlanetScale's architecture is simple:

Your App
  ↓
PlanetScale Pooler (connection management only)
  ↓
PostgreSQL

Two hops. No integration layer. No REST translation. No auth checks before queries.

Benchmarked query performance (50GB database, US-EAST-1):

Simple SELECT:

  • PlanetScale: 1.1ms p50, 1.8ms p95
  • Direct PostgreSQL: 0.8ms p50, 1.2ms p95
  • Overhead: 0.3ms (pooler only)

Complex JOIN (5 tables):

  • PlanetScale: 12.3ms p50, 19.7ms p95
  • Direct PostgreSQL: 11.8ms p50, 18.9ms p95
  • Overhead: 0.5ms (pooler only)

Write-heavy workload (1000 inserts/second):

  • PlanetScale: 2.4ms p50, 4.1ms p95
  • Direct PostgreSQL: 2.1ms p50, 3.8ms p95
  • Overhead: 0.3ms (pooler only)

The performance difference is connection pooling overhead. Not integration layer tax.

Database Branching: Schema Changes Without Fear

The killer feature that sold us:

Traditional schema migration:

  1. Write migration
  2. Test on local database (different data from production)
  3. Test on staging database (outdated data from production)
  4. Run on production (hope it works)
  5. If it breaks, scramble to rollback

PlanetScale branching:

  1. Create branch from production (same data, different schema)
  2. Run migration on branch
  3. Test thoroughly with production data
  4. Create deploy request
  5. Merge when ready, instant rollback if needed
# Create branch
pscale branch create my-app add-user-preferences

# Connect to branch (production data, your schema changes)
DATABASE_URL=$(pscale connection-string my-app add-user-preferences)

# Run migration
psql $DATABASE_URL < migrations/001_add_preferences.sql

# Test with production data
# No "hope it works" moment

# Deploy when ready
pscale deploy-request create my-app add-user-preferences

# Merge or rollback
pscale deploy-request merge my-app 123  # or
pscale deploy-request close my-app 123  # instant rollback

We've run 847 schema migrations in 18 months. Zero production incidents from bad migrations. Branching eliminated the risk.

Better Auth: Portable Authentication

Our auth stack pairs with PlanetScale:

// Better Auth with PlanetScale
import { betterAuth } from "better-auth";
import { Pool } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL, // PlanetScale
});

export const auth = betterAuth({
  database: pool,
  emailAndPassword: { enabled: true },
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    },
  },
});

Better Auth doesn't care where PostgreSQL runs. PlanetScale, Neon, vanilla RDS, bare metal—all work identically.

Compare to Supabase's coupled auth:

// Supabase auth - locked to platform
import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  process.env.SUPABASE_URL, // Must be Supabase
  process.env.SUPABASE_KEY, // Platform-specific
);

// Database access tied to auth client
await supabase.auth.signUp({ email, password });
await supabase.from("users").select("*"); // Same client

The convenience is coupling. The integration is lock-in.

Real Architecture

Our production setup:

┌────────────────┐
│  Cloudflare    │  Better Auth (edge)
│   Workers      │  Session management
└───────┬────────┘
        │
   ┌────┴─────────────┬──────────────┐
   │                  │              │
┌──▼───────────┐  ┌──▼──────────┐  ┌▼─────────┐
│ PlanetScale  │  │ TurboPuffer │  │ R2/S3    │
│  PostgreSQL  │  │   Vectors   │  │ Storage  │
│              │  │             │  │          │
│ Multi-cloud  │  └─────────────┘  └──────────┘
│ + Bare metal │
└──────────────┘

Each service independent. Each replaceable. No special integration.

Standard protocols:

  • PostgreSQL wire protocol (database)
  • HTTP REST (auth, vector search)
  • S3-compatible API (storage)

The Migration Tax You'll Pay

Supabase to PlanetScale isn't trivial. Here's the real effort:

Database Migration (Medium)

Both are PostgreSQL, but Supabase has custom functions:

-- Supabase-specific functions that break
auth.uid()           -- Returns current user ID
auth.email()         -- Returns current user email
storage.foldername() -- Storage bucket references

Replace with application-level logic:

// Before: RLS with auth.uid()
CREATE POLICY user_select ON users
  FOR SELECT USING (id = auth.uid());

// After: Application-level filtering
const users = await db.select()
  .from(users)
  .where(eq(users.id, session.userId));

Migration effort: 2-5 days depending on RLS usage.

Auth Migration (Medium)

Supabase Auth → Better Auth:

// Export user data from Supabase
const { data: users } = await supabase.auth.admin.listUsers();

// Import to Better Auth (supports password hash migration)
for (const user of users) {
  await auth.createUser({
    email: user.email,
    password: user.encrypted_password, // bcrypt hash preserved
    emailVerified: user.email_confirmed_at !== null,
  });
}

Update application code:

// Before: Supabase Auth
const {
  data: { user },
} = await supabase.auth.getUser();

// After: Better Auth
const session = await auth.api.getSession({ headers });
const user = session?.user;

Migration effort: 3-5 days for typical application.

Storage Migration (Low)

Both use S3-compatible APIs:

// Before: Supabase Storage
await supabase.storage.from("avatars").upload(path, file);

// After: Direct S3/R2
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({ endpoint: R2_ENDPOINT });
await s3.send(
  new PutObjectCommand({
    Bucket: "avatars",
    Key: path,
    Body: file,
  }),
);

Migration effort: 1-2 days to update storage calls.

Total Migration Timeline

Small app (<10k users): 1-2 weeks Medium app (<100k users): 2-3 weeks Large app (>100k users, complex RLS): 3-4 weeks

Worth it? Depends on your timeline:

  • 0-6 months old: Too early, premature optimization
  • 6-18 months old: Perfect time to migrate
  • 18+ months old: The technical debt is growing

The Independence Stack

PlanetScale fits our philosophy:

Infrastructure: Control Plane (multicloud orchestration) LLM Framework: Mirascope (no abstraction hell) Vector Search: TurboPuffer (cost-effective performance) Database: PlanetScale PostgreSQL (fast, portable, multi-cloud) Auth: Better Auth (database-agnostic)

Each piece replaceable. Each piece best-in-class for one job.

No platform lock-in. No integrated convenience that becomes permanent dependency.

Boring infrastructure that survives cloud outages.

The Bottom Line

We chose PlanetScale for three reasons:

  1. Performance: Direct PostgreSQL access, minimal overhead
  2. Portability: Multi-cloud and bare metal deployment options
  3. Reliability: When AWS goes down, we don't

Supabase's integration is coupling. The convenience becomes lock-in. The platform overhead adds latency. Single cloud dependency creates outage risk.

Neon's autoscaling is impressive. The production pricing is broken. Single cloud dependency persists.

PlanetScale is boring PostgreSQL with database branching. Fast. Portable. Multi-cloud. Deployable to bare metal.

The infrastructure that doesn't trap us.


Need help migrating from Supabase to an independent stack? We've architected multi-cloud database deployments for three clients. Let's talk about performance, portability, and surviving cloud outages.

Grey Haven
Grey HavenApplied AI Venture Studio