MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Serverless Databases: PlanetScale, Fauna, and DynamoDB

Compare serverless databases: auto-scaling, pay-per-use, and developer experience.

ServerlessDatabasePlanetScaleDynamoDB

By MinhVo

Introduction

The rise of serverless computing has fundamentally changed how we think about database infrastructure. Traditional database servers require capacity planning, maintenance windows, and scaling expertise that many development teams lack. Serverless databases eliminate these operational burdens by automatically scaling capacity, handling failovers, and charging only for actual usage.

PlanetScale, Fauna, and DynamoDB represent three distinct approaches to serverless data management. PlanetScale brings MySQL compatibility with Vitess-powered horizontal scaling. Fauna offers a globally distributed document-relational database with ACID transactions. DynamoDB provides AWS-native key-value and document storage with single-digit millisecond performance at any scale.

Database architecture

Understanding Serverless Databases: Core Concepts

Serverless databases share several fundamental characteristics that differentiate them from traditional managed databases:

Automatic Scaling: Serverless databases scale compute and storage independently based on demand. There's no need to provision instances or configure auto-scaling policies—the platform handles it transparently.

Pay-Per-Use Pricing: Instead of paying for provisioned capacity, you pay for actual reads, writes, and storage consumed. This model aligns costs directly with application usage, making serverless databases economical for variable workloads.

Zero Administration: The cloud provider handles patching, backups, replication, and failover. Development teams focus on schema design and queries rather than operational maintenance.

Global Distribution: Many serverless databases offer multi-region replication with configurable consistency models, enabling low-latency access for global user bases.

Data Model Paradigms

Serverless databases employ different data models, each with distinct trade-offs:

Key-Value: DynamoDB uses a key-value model optimized for single-item lookups by primary key. This model excels at high-throughput, low-latency access patterns but requires careful key design for complex queries.

Document-Relational: Fauna combines document flexibility with relational capabilities. Collections contain documents with schema flexibility, while Fauna Query Language (FQL) enables joins and transactions across collections.

Relational with Horizontal Scaling: PlanetScale maintains MySQL compatibility while using Vitess to transparently shard data across multiple nodes. Developers write standard SQL while the platform handles distribution.

Cloud database services

Architecture and Design Patterns

DynamoDB: Single-Table Design

DynamoDB's most effective pattern uses a single table with overloaded attributes. This design maximizes query flexibility while minimizing operational complexity:

// Single table design for an e-commerce application
{
  TableName: 'ECommerce',
  KeySchema: [
    { AttributeName: 'PK', KeyType: 'HASH' },
    { AttributeName: 'SK', KeyType: 'RANGE' }
  ],
  AttributeDefinitions: [
    { AttributeName: 'PK', AttributeType: 'S' },
    { AttributeName: 'SK', AttributeType: 'S' }
  ]
}
 
// Example items
// User
{ PK: 'USER#123', SK: 'PROFILE', name: 'John', email: 'john@example.com' }
 
// User's orders
{ PK: 'USER#123', SK: 'ORDER#001', total: 99.99, status: 'shipped' }
{ PK: 'USER#123', SK: 'ORDER#002', total: 149.99, status: 'pending' }
 
// Order details (accessed directly)
{ PK: 'ORDER#001', SK: 'DETAILS', items: [...], shipping: {...} }

PlanetScale: Branching Workflow

PlanetScale's branching model enables safe schema changes through deploy requests:

-- Create a branch
CREATE BRANCH add-user-avatar FROM main;
 
-- Make schema changes on the branch
ALTER TABLE users ADD COLUMN avatar_url VARCHAR(255);
 
-- Test with production data (non-blocking)
-- Open a deploy request to merge changes
-- PlanetScale handles online schema migration

Fauna: Document Relationships

Fauna's document-relational model uses references and indexes for relationships:

// Create a user document
const user = await client.query(
  q.Create(q.Collection('users'), {
    data: {
      name: 'Jane Smith',
      email: 'jane@example.com',
      createdAt: q.Now()
    }
  })
);
 
// Create an order referencing the user
const order = await client.query(
  q.Create(q.Collection('orders'), {
    data: {
      user: user.ref,
      items: [
        { product: 'prod_123', quantity: 2, price: 29.99 }
      ],
      total: 59.98,
      status: 'pending'
    }
  })
);
 
// Query orders with user data (join)
const ordersWithUsers = await client.query(
  q.Map(
    q.Paginate(q.Match(q.Index('orders_by_user'), user.ref)),
    q.Lambda('orderRef', {
      order: q.Get(q.Var('orderRef')),
      user: q.Get(q.Select(['data', 'user'], q.Get(q.Var('orderRef'))))
    })
  )
);

Database comparison

Step-by-Step Implementation

DynamoDB with AWS SDK v3

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand, GetCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
 
const client = new DynamoDBClient({ region: 'us-east-1' });
const docClient = DynamoDBDocumentClient.from(client);
 
// Create item
async function createUser(user) {
  await docClient.send(new PutCommand({
    TableName: 'Users',
    Item: {
      PK: `USER#${user.id}`,
      SK: 'PROFILE',
      ...user,
      createdAt: new Date().toISOString()
    }
  }));
}
 
// Query with GSI
async function getUserOrders(userId) {
  const result = await docClient.send(new QueryCommand({
    TableName: 'ECommerce',
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
    ExpressionAttributeValues: {
      ':pk': `USER#${userId}`,
      ':sk': 'ORDER#'
    }
  }));
  return result.Items;
}
 
// Transactional write
async function transferFunds(fromAccount, toAccount, amount) {
  await docClient.send(new TransactWriteCommand({
    TransactItems: [
      {
        Update: {
          TableName: 'Accounts',
          Key: { PK: `ACCOUNT#${fromAccount}`, SK: 'BALANCE' },
          UpdateExpression: 'SET balance = balance - :amount',
          ConditionExpression: 'balance >= :amount',
          ExpressionAttributeValues: { ':amount': amount }
        }
      },
      {
        Update: {
          TableName: 'Accounts',
          Key: { PK: `ACCOUNT#${toAccount}`, SK: 'BALANCE' },
          UpdateExpression: 'SET balance = balance + :amount',
          ExpressionAttributeValues: { ':amount': amount }
        }
      }
    ]
  }));
}

PlanetScale with Prisma

// schema.prisma
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider     = "mysql"
  url          = env("DATABASE_URL")
  relationMode = "prisma"
}
 
model User {
  id        String    @id @default(cuid())
  email     String    @unique
  name      String
  avatarUrl String?   @map("avatar_url")
  orders    Order[]
  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime  @updatedAt @map("updated_at")
 
  @@map("users")
}
 
model Order {
  id        String      @id @default(cuid())
  userId    String      @map("user_id")
  user      User        @relation(fields: [userId], references: [id])
  total     Decimal     @db.Decimal(10, 2)
  status    OrderStatus @default(PENDING)
  items     OrderItem[]
  createdAt DateTime    @default(now()) @map("created_at")
 
  @@index([userId])
  @@map("orders")
}
 
enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
}
// Using Prisma with PlanetScale
import { PrismaClient } from '@prisma/client';
 
const prisma = new PrismaClient();
 
async function createUserWithOrder() {
  const user = await prisma.user.create({
    data: {
      email: 'alice@example.com',
      name: 'Alice Johnson',
      orders: {
        create: {
          total: 149.99,
          status: 'PENDING',
          items: {
            create: [
              { productId: 'prod_1', quantity: 2, price: 49.99 },
              { productId: 'prod_2', quantity: 1, price: 49.99 }
            ]
          }
        }
      }
    },
    include: { orders: { include: { items: true } } }
  });
  
  return user;
}
 
async function getUserWithOrders(userId: string) {
  return prisma.user.findUnique({
    where: { id: userId },
    include: {
      orders: {
        orderBy: { createdAt: 'desc' },
        take: 10,
        include: { items: true }
      }
    }
  });
}

Fauna with Official Driver

import faunadb from 'faunadb';
const { query: q } = faunadb;
 
const client = new faunadb.Client({
  secret: process.env.FAUNA_SECRET,
  domain: 'db.fauna.com'
});
 
// Create user with validation
async function createUser(userData: { name: string; email: string }) {
  return client.query(
    q.Create(q.Collection('users'), {
      data: {
        ...userData,
        createdAt: q.Now()
      }
    })
  );
}
 
// Complex query with pagination
async function searchProducts(query: string, cursor?: string) {
  return client.query(
    q.Map(
      q.Paginate(
        q.Search(q.Index('products_search'), { query }),
        { size: 20, after: cursor ? q.Ref(q.Collection('products'), cursor) : undefined }
      ),
      q.Lambda(['ref', 'score'], {
        product: q.Get(q.Var('ref')),
        score: q.Var('score')
      })
    )
  );
}
 
// Multi-document transaction
async function placeOrder(userId: string, items: OrderItem[]) {
  return client.query(
    q.Do(
      // Deduct inventory for each item
      ...items.map(item =>
        q.Update(
          q.Ref(q.Collection('products'), item.productId),
          {
            data: {
              stock: q.Subtract(
                q.Select(['data', 'stock'], q.Get(q.Ref(q.Collection('products'), item.productId))),
                item.quantity
              )
            }
          }
        )
      ),
      // Create the order
      q.Create(q.Collection('orders'), {
        data: {
          user: q.Ref(q.Collection('users'), userId),
          items,
          total: items.reduce((sum, item) => sum + item.price * item.quantity, 0),
          status: 'confirmed',
          createdAt: q.Now()
        }
      })
    )
  );
}

Real-World Use Cases and Case Studies

E-Commerce Platform with DynamoDB

A rapidly growing e-commerce startup migrated from PostgreSQL to DynamoDB to handle flash sales that generated 100,000 requests per second. The single-table design pattern allowed them to serve product catalogs, user profiles, and order history from a single table with consistent single-digit millisecond latency.

Key wins:

  • Scaled from 1,000 to 100,000 RPS without capacity planning
  • Reduced database costs by 70% using on-demand pricing during variable traffic
  • Eliminated read replica management and connection pooling complexity

SaaS Application with PlanetScale

A B2B SaaS company chose PlanetScale for its MySQL compatibility and non-blocking schema migrations. Their workflow involves developers creating branches for schema changes, testing against production data, and deploying without downtime.

Key wins:

  • Zero-downtime schema migrations for 50+ tables with millions of rows
  • Developer familiarity with MySQL tooling and ORMs
  • Connection pooling handled by PlanetScale's serverless driver

Global Application with Fauna

A social platform with users across six continents uses Fauna's global distribution to serve data from the nearest region. The ACID transaction support ensures consistent behavior for features like friend requests and notifications that span multiple collections.

Key wins:

  • Sub-50ms reads for users in all regions without manual replication
  • Strong consistency for financial operations without custom locking logic
  • GraphQL integration reduced API development time by 40%

Best Practices for Production

  1. Model Data for Access Patterns: Design your schema around how you query data, not how you store it. DynamoDB requires this mindset, but PlanetScale and Fauna also benefit from access-pattern-driven design.

  2. Use Indexes Strategically: Create indexes for your most common queries. In DynamoDB, use GSI for alternative access patterns. In PlanetScale, follow MySQL indexing best practices. In Fauna, create indexes for frequently used terms.

  3. Implement Connection Pooling: Use serverless drivers that handle connection pooling automatically—PlanetScale's @planetscale/database, Fauna's driver, and DynamoDB's SDK all manage connections efficiently.

  4. Handle Throttling Gracefully: Serverless databases may throttle requests during traffic spikes. Implement exponential backoff and retry logic in your application layer.

  5. Monitor Costs Actively: Serverless pricing can surprise you. Set up billing alerts, monitor read/write unit consumption, and optimize access patterns to minimize costs.

  6. Use Transactions Judiciously: Transactions provide consistency guarantees but add latency and cost. Use them for operations that truly require atomicity, and prefer eventually consistent patterns when possible.

  7. Implement Caching: Reduce database load with application-level caching. Use Redis or in-memory caches for frequently accessed data with appropriate TTLs.

  8. Test with Production-Like Data: Serverless databases perform differently at scale. Test with representative data volumes and access patterns before launching.

  9. Design for Global Distribution: If your users are global, choose databases that support multi-region replication. Configure read replicas in regions closest to your users.

  10. Plan for Schema Evolution: Use migration tools and version your schema changes. PlanetScale's branching makes this natural; for DynamoDB, design tables to accommodate new attributes without migrations.

Common Pitfalls and Solutions

PitfallImpactSolution
DynamoDB hot partitionsUneven throughput, throttlingUse high-cardinality partition keys, implement write sharding
PlanetScale connection limitsConnection exhaustion under loadUse serverless driver with connection pooling, implement retry logic
Fauna document size limitsWrite failures for large documentsSplit large documents, use references for related data
N+1 query patternsExcessive read operationsUse batch operations, implement DataLoader patterns
Missing indexes on PlanetScaleSlow queries at scaleAnalyze query plans, add indexes for common filter columns
DynamoDB scan operationsFull table scans, high costsUse Query with key conditions, implement pagination
Ignoring consistency modelsStale reads in FaunaChoose appropriate consistency level per query

Performance Optimization

DynamoDB: Optimizing for Cost and Performance

// Use sparse indexes to reduce storage and improve performance
{
  TableName: 'Orders',
  GlobalSecondaryIndexes: [
    {
      IndexName: 'GSI1',
      KeySchema: [
        { AttributeName: 'GSI1PK', KeyType: 'HASH' },
        { AttributeName: 'GSI1SK', KeyType: 'RANGE' }
      ],
      Projection: { ProjectionType: 'KEYS_ONLY' } // Only store keys
    }
  ]
}
 
// Batch reads to reduce API calls
const { BatchGetCommand } = require('@aws-sdk/lib-dynamodb');
 
async function getUsers(userIds) {
  const result = await docClient.send(new BatchGetCommand({
    RequestItems: {
      'Users': {
        Keys: userIds.map(id => ({ PK: `USER#${id}`, SK: 'PROFILE' }))
      }
    }
  }));
  return result.Responses.Users;
}

PlanetScale: Query Optimization

-- Use EXPLAIN to analyze query performance
EXPLAIN SELECT u.*, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2024-01-01'
GROUP BY u.id;
 
-- Add covering index for common queries
ALTER TABLE orders ADD INDEX idx_user_status (user_id, status, created_at);

Comparison with Alternatives

FeatureDynamoDBPlanetScaleFauna
Data ModelKey-Value/DocumentRelational (MySQL)Document-Relational
Query LanguagePartiQL / SDKSQLFQL
Global DistributionGlobal TablesRead ReplicasNative Multi-Region
TransactionsSingle-region ACIDFull ACIDMulti-document ACID
Schema FlexibilitySchemalessSchema-on-writeSchemaless
Max Document Size400KBRow size limits1MB
Connection ModelHTTP/HTTPSConnection poolingHTTP/HTTPS
Pricing ModelPer request unitsCompute + StoragePer operation
Best ForHigh-throughput key-valueMySQL migration, complex queriesGlobal apps, flexible schema

Advanced Patterns and Techniques

DynamoDB Streams + Lambda for Event Sourcing

// Event sourcing with DynamoDB Streams
const { DynamoDBStreamsClient, DescribeStreamCommand } = require('@aws-sdk/client-dynamodb-streams');
 
exports.streamHandler = async (event) => {
  for (const record of event.Records) {
    const { eventName, dynamodb } = record;
    
    // Process change events
    switch (eventName) {
      case 'INSERT':
        await processInsert(dynamodb.NewImage);
        break;
      case 'MODIFY':
        await processUpdate(dynamodb.OldImage, dynamodb.NewImage);
        break;
      case 'REMOVE':
        await processDelete(dynamodb.OldImage);
        break;
    }
  }
};

PlanetScale with Drizzle ORM

import { drizzle } from 'drizzle-orm/planetscale-serverless';
import { connect } from '@planetscale/database';
import { users, orders } from './schema';
 
const connection = connect({ url: process.env.DATABASE_URL });
const db = drizzle(connection);
 
// Type-safe queries
const result = await db.select()
  .from(users)
  .leftJoin(orders, eq(users.id, orders.userId))
  .where(gt(users.createdAt, new Date('2024-01-01')));

Testing Strategies

// DynamoDB Local for testing
const { DynamoDBClient, CreateTableCommand } = require('@aws-sdk/client-dynamodb');
 
beforeAll(async () => {
  const client = new DynamoDBClient({
    endpoint: 'http://localhost:8000',
    region: 'local',
    credentials: { accessKeyId: 'local', secretAccessKey: 'local' }
  });
  
  await client.send(new CreateTableCommand({
    TableName: 'TestTable',
    KeySchema: [{ AttributeName: 'PK', KeyType: 'HASH' }],
    AttributeDefinitions: [{ AttributeName: 'PK', AttributeType: 'S' }],
    BillingMode: 'PAY_PER_REQUEST'
  }));
});
 
// PlanetScale test database
beforeAll(async () => {
  // Use PlanetScale test branch
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  await prisma.$executeRaw`TRUNCATE TABLE users`;
  await prisma.$executeRaw`TRUNCATE TABLE orders`;
});

Future Outlook

Serverless databases continue evolving rapidly:

DynamoDB: Enhanced PartiQL support, improved Global Tables with conflict resolution, and tighter integration with AI/ML services.

PlanetScale: Serverless driver improvements, built-in connection pooling, and expanded MySQL compatibility for complex queries.

Fauna: GraphQL-first development, improved FQL ergonomics, and enhanced multi-region consistency options.

The convergence of serverless databases with edge computing will enable data access from edge locations with single-digit millisecond latency globally. New entrants like Neon (serverless PostgreSQL) and CockroachDB Serverless are expanding options for teams requiring SQL compatibility.

Database Connection Strategies

Serverless functions create new database connections on every cold start, which can exhaust database connection limits. Use connection pooling services like Amazon RDS Proxy, PgBouncer, or PlanetScale's built-in connection pooling to manage connections efficiently. Configure your serverless functions to reuse connections across warm invocations by initializing the database client outside the handler function. Set connection timeouts appropriate for your function's execution time to prevent idle connections from consuming pool capacity.

Conclusion

Serverless databases represent the future of data management for cloud-native applications. DynamoDB excels for high-throughput key-value workloads, PlanetScale offers familiar MySQL semantics with modern scaling, and Fauna provides global distribution with ACID guarantees.

Key takeaways:

  1. Choose DynamoDB for predictable high-throughput workloads with well-defined access patterns
  2. Select PlanetScale when your team knows MySQL and needs non-blocking schema migrations
  3. Use Fauna for globally distributed applications requiring strong consistency without operational complexity
  4. Design your data model around access patterns, not storage convenience
  5. Monitor costs actively—serverless pricing rewards efficient access patterns

Start with the database that matches your team's expertise and application's access patterns. As your application evolves, the serverless model ensures you never pay for idle capacity while automatically scaling to meet demand.