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.
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.
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 migrationFauna: 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'))))
})
)
);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
-
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.
-
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.
-
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.
-
Handle Throttling Gracefully: Serverless databases may throttle requests during traffic spikes. Implement exponential backoff and retry logic in your application layer.
-
Monitor Costs Actively: Serverless pricing can surprise you. Set up billing alerts, monitor read/write unit consumption, and optimize access patterns to minimize costs.
-
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.
-
Implement Caching: Reduce database load with application-level caching. Use Redis or in-memory caches for frequently accessed data with appropriate TTLs.
-
Test with Production-Like Data: Serverless databases perform differently at scale. Test with representative data volumes and access patterns before launching.
-
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.
-
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
| Pitfall | Impact | Solution |
|---|---|---|
| DynamoDB hot partitions | Uneven throughput, throttling | Use high-cardinality partition keys, implement write sharding |
| PlanetScale connection limits | Connection exhaustion under load | Use serverless driver with connection pooling, implement retry logic |
| Fauna document size limits | Write failures for large documents | Split large documents, use references for related data |
| N+1 query patterns | Excessive read operations | Use batch operations, implement DataLoader patterns |
| Missing indexes on PlanetScale | Slow queries at scale | Analyze query plans, add indexes for common filter columns |
| DynamoDB scan operations | Full table scans, high costs | Use Query with key conditions, implement pagination |
| Ignoring consistency models | Stale reads in Fauna | Choose 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
| Feature | DynamoDB | PlanetScale | Fauna |
|---|---|---|---|
| Data Model | Key-Value/Document | Relational (MySQL) | Document-Relational |
| Query Language | PartiQL / SDK | SQL | FQL |
| Global Distribution | Global Tables | Read Replicas | Native Multi-Region |
| Transactions | Single-region ACID | Full ACID | Multi-document ACID |
| Schema Flexibility | Schemaless | Schema-on-write | Schemaless |
| Max Document Size | 400KB | Row size limits | 1MB |
| Connection Model | HTTP/HTTPS | Connection pooling | HTTP/HTTPS |
| Pricing Model | Per request units | Compute + Storage | Per operation |
| Best For | High-throughput key-value | MySQL migration, complex queries | Global 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:
- Choose DynamoDB for predictable high-throughput workloads with well-defined access patterns
- Select PlanetScale when your team knows MySQL and needs non-blocking schema migrations
- Use Fauna for globally distributed applications requiring strong consistency without operational complexity
- Design your data model around access patterns, not storage convenience
- 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.