From High RDS Costs to Near Zero with DynamoDB: A Detailed Case Study Using a Serverless LLM Chat Application.
Freelance Consultant, CISSP, CTO @ Shortcats
15 min read · April 14, 2025
Databases often represent a significant cost factor when building applications – and our SaaS startup, Shortcats, was no exception. Our initial choice, RDS PostgreSQL, combined with AWS Lambda and multiple environments (Dev, Staging, Prod), quickly became a major cost driver.
The search for a scalable and cost-effective alternative led us to Amazon DynamoDB. But instead of a long explanation, here's what happened:
Shortcats' AWS costs after migrating to DynamoDB (BEFORE the DynamoDB price reduction in November 2024)
As part of the series "Building a serverless LLM-based Chat-Service on AWS", this post focuses on implementing the data layer using DynamoDB for our chat service. We'll delve into our methodology, including Single-Table Design, using ElectroDB as a type-safe mapper, and the architecture of our Data Access Layer.
You can find the complete code for this part in this GitHub repository.
DynamoDB is AWS's fully-managed NoSQL database service. "Fully-managed" means: AWS handles operations, maintenance, and scaling – no server administration for you, or your DevOps team (which you almost 😉 don't need anymore). Unlike relational databases (like PostgreSQL), it offers a flexible schema.
DynamoDB shines in serverless architectures because:
Especially in the Lambda context, DynamoDB solves typical problems faced by relational databases:
Choosing DynamoDB offers specific benefits, particularly in a serverless context, but also comes with challenges. The following table contrasts the most important points:
✅ Massive Scalability & Performance
✅ Fully Managed
✅ Ideal for Serverless (Pay-per-Use)
✅ Fast Setup & Development
✅ Deep AWS Ecosystem Integration
✅ Flexible NoSQL Data Model
✅ Optional Global Tables
❌ Steep Learning Curve: Different mindset (Single-Table, Access Patterns)
❌ Limited Query Flexibility: No SQL, no JOINs, requires indexes
❌ Max Item Size: 400 KB limit per item
❌ Eventual Consistency by Default (Strong Consistency costs more)
❌ Pricing details can be complex (WCU/RCU calculation)
Many of the listed "disadvantages," particularly the limited query capabilities and lack of JOINs, are intentional design decisions. They are key to DynamoDB's extreme scalability and performance.
The approach requires upfront data modeling (e.g., denormalization, Single-Table Design) to optimize queries so that data can be retrieved with few, fast accesses. You consciously trade the runtime query flexibility familiar from SQL for a massive increase in performance and scalability – an often desirable trade-off for modern, highly scalable applications.
A crucial factor in database selection is cost, especially in serverless architectures with AWS Lambda. How does DynamoDB compare to RDS (e.g., PostgreSQL) and Aurora Serverless v2? Here we look at the costs in detail, particularly considering the need for an RDS Proxy when using relational databases with Lambda.
The RDS Proxy: This is often essential when using Lambda with RDS/Aurora to manage connection limits, but it incurs additional, significant costs (based on vCPUs/ACUs) that apply even at low load.
We consider the production environment. For RDS/Aurora, costs for Dev/Staging are additional, while for DynamoDB On-Demand these are minimal. Assumptions: 4KB item size, 50/50 read/write distribution. Backup costs not included.
Cost Type | DynamoDB | Aurora | RDS |
---|---|---|---|
Data Storage | $0.00 (FT) | $0.50 | $0.58 |
Instance/Capacity Costs | N/A | $43.80 (min 0.5 ACU) | $12.41 |
Throughput Costs | $0.79 | $0.00 | $0.00 |
RDS Proxy Costs | N/A | $87.60 (min 8 ACUs) | $21.90 (min 2 vCPUs) |
Total Costs (Month) | ~$0.79 | ~$131.90 | ~$34.89 |
Finding 1: DynamoDB is practically free thanks to the Free Tier. The fixed costs for instance/ACU and especially the RDS Proxy make RDS/Aurora significantly more expensive at low load.
Cost Type | DynamoDB | Aurora | RDS |
---|---|---|---|
Data Storage | $118.75 | $50.00 | $57.50 |
Instance/Capacity Costs | n/a | $350.40 (scaled) | $49.64 |
Throughput Costs | $113.40 | $0.00 | $0.00 |
RDS Proxy Costs | n/a | $87.60 (min. 8 ACUs) | $21.90 (min. 2 vCPUs) |
Total Costs (Month) | ~$232.15 | ~$488.00 | ~$129.04 |
Insight 2: Here, RDS PostgreSQL becomes the cheapest option. The instance and proxy costs are lower than the now significant throughput costs of DynamoDB On-Demand.
Cost Type | DynamoDB | Aurora | RDS |
---|---|---|---|
Data Storage | $1,243.75 | $500.00 | $575.00 |
Instance/Capacity Costs | n/a | $1,401.60 (scaled) | $730.00 |
Throughput Costs | $1,134.00 | $0.00 | $0.00 |
RDS Proxy Costs | n/a | $87.60 (min. 8 ACUs) | $87.60 (matching) |
Total Costs (Month) | ~$2,377.75 | ~$1,989.20 | ~$1,392.60 |
Insight 3: RDS PostgreSQL remains the cheapest option in a pay-as-you-go comparison under high load. The pure throughput costs make DynamoDB On-Demand the most expensive alternative without optimization.
For consistently high workloads, significant savings can be achieved through commitment (Reserved Instances/Capacity) and potentially caching (DAX):
Cost Component | DynamoDB (Optimized*) | RDS PostgreSQL (Optimized*) |
---|---|---|
Data Storage | ~$868.75 (potentially less) | ~$575.00 |
Capacity/Instance | ~$9.54 (Reserved Capacity) | ~$292.00 (Reserved Inst.) |
Throughput (Remaining)/I/O | Included in RC / low $ | Low $ |
Caching/Proxy | ~$99.28 (DAX, optional) | ~$35.04 (RDS Proxy RI) |
Total Costs (Month) | ~$977.57 (with DAX) | ~$902.04 |
Optimization Insight: With optimization (especially reservations), costs decrease massively. RDS often remains slightly cheaper, but DynamoDB comes very close and eliminates most of the operational overhead for DB management (patches, scaling, etc.), which saves personnel costs.
Pure costs are only part of the picture. Technical fit, scalability, development speed, and management effort must also factor into the decision.
Those coming from relational databases are tempted to create a separate table for each entity (e.g., Chats, Messages). For DynamoDB, however, the Single-Table Design (STD) is often the key to performance and cost-efficiency: All entity types reside in a single table.
This approach requires more upfront planning and a shift in thinking. You need to know your access patterns precisely, as subsequent changes can be more complex than with relational models. In return, you gain the performance and scalability DynamoDB is known for.
In the next step, we'll look at how we can specifically implement this STD concept for our chat service using TypeScript and ElectroDB.
Theory is good, practice is better. For implementing our Single-Table Design in TypeScript, we use ElectroDB. This library significantly simplifies working with DynamoDB:
Below, we highlight the essential parts of the implementation; the complete code, including E2E tests and the entire Data Access Layer (DAL), can be found in the following GitHub repository.
We define two entities (Chat
, ChatMessage
), both stored in the same DynamoDB table (ChatsTable
).
The magic lies in the definition of the indexes
:
// ChatModel.ts
// DDB Client Setup (Conceptual)
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
const client = new DynamoDBClient({}); // Configure as needed
// ElectroDB Setup
import { Entity, Service, type EntityItem } from "electrodb";
export const TABLE_NAME = "ChatsTable";
// -------- Chat Entity Definition --------
const ChatEntity = new Entity(
{
model: {
entity: "chat",
version: "1",
service: "chatservice",
},
attributes: {
chatId: {
type: "string",
required: true,
default: () => crypto.randomUUID(),
},
userId: { type: "string", required: true },
name: {
type: "string",
required: true,
validate: (v) => v.length > 0 && v.length <= 100,
},
createdAt: {
type: "string",
readOnly: true,
required: true,
default: () => new Date().toISOString(),
},
updatedAt: {
type: "string",
required: true,
default: () => new Date().toISOString(),
// update the updatedAt with every write
set: () => new Date().toISOString(),
},
},
indexes: {
// Primary Key: Access Chat by chatId
primary: {
pk: { field: "pk", composite: ["chatId"], template: "CHAT#${chatId}" },
sk: { field: "sk", composite: [], template: "METADATA" },
collection: "chatAndMessages",
},
// GSI1: Access Chats by userId, sorted by updatedAt
byUser: {
index: "gsi1",
pk: {
field: "gsi1pk",
composite: ["userId"],
template: "USER#${userId}",
},
sk: {
field: "gsi1sk",
composite: ["updatedAt"],
template: "CHAT#${updatedAt}",
},
},
},
},
{ table: TABLE_NAME, client }
);
// -------- ChatMessage Entity Definition --------
const ChatMessageEntity = new Entity(
{
model: {
entity: "chatmessage",
version: "1",
service: "chatservice",
},
attributes: {
chatId: { type: "string", required: true }, // Used for PK
messageId: {
type: "string",
required: true,
default: () => crypto.randomUUID(),
}, // Used for SK
userId: { type: "string", required: true }, // User who wrote the message
createdAt: {
type: "string",
readOnly: true,
required: true,
default: () => new Date().toISOString(),
}, // Used for SK
updatedAt: {
type: "string",
required: true,
default: () => new Date().toISOString(),
set: () => new Date().toISOString(),
},
content: { type: "string", required: true },
reasoningContent: { type: "string", required: false },
role: { type: ["user", "assistant"] as const, required: true },
},
indexes: {
// Primary Key: Access Messages by chatId, sorted by createdAt
primary: {
pk: { field: "pk", composite: ["chatId"], template: "CHAT#${chatId}" },
sk: {
field: "sk",
composite: ["createdAt", "messageId"],
template: "MSG#${createdAt}#${messageId}",
},
collection: "chatAndMessages",
},
},
},
{ table: TABLE_NAME, client }
);
// -------- Service Definition --------
const ChatService = new Service(
{
// Expose entities through the service
chat: ChatEntity,
chatMessage: ChatMessageEntity,
},
{ client, table: TABLE_NAME }
);
// -------- Types for usage elsewhere --------
export type ChatEntityType = typeof ChatService.entities.chat;
export type ChatItem = EntityItem<ChatEntityType>;
export type MessageEntityType = typeof ChatService.entities.chatMessage;
export type MessageItem = EntityItem<MessageEntityType>;
export default ChatService;
Chat
and ChatMessage
share the pk
(CHAT#{"<chatId>"}
), which groups them.
They differ in the sk
(METADATA
vs. MSG#...
).byUser-Index
): Enables an alternative access path (all chats for a user).chatAndMessages
: Allows retrieving a chat and its messages with a
single query targeting the pk
(CHAT#{"<chatId>"}
).With the ChatService
, we can now access our data in a type-safe manner:
// services/chats.ts
import { EntityItem } from "electrodb";
import ChatService, {
type ChatItem,
type MessageItem,
} from "./models/ChatModel";
export class Chats {
private readonly chatEntity = ChatService.entities.chat;
private readonly messageEntity = ChatService.entities.chatMessage;
public async createChat(userId: string, name: string): Promise<ChatItem> {
try {
if (!userId)
throw new Error("userId is required");
const chat = await this.chatEntity
.create({
userId,
name,
})
.go();
return chat.data;
} catch (error) {
// ...
}
}
public async createMessage(
chatId: string,
userId: string,
content: string,
role: "user" | "assistant",
reasoningContent?: string
): Promise<MessageItem> {
try {
if (!userId)
throw new Error("userId is required");
// First check if the chat exists and belongs to the user
const chat = await this.getChatForUser(chatId, userId);
if (!chat)
throw new Error(`Chat with ID ${chatId} not found or access denied`);
// Create the message
const { data: message } = await this.messageEntity
.create({
chatId,
userId,
content,
role,
reasoningContent,
})
.go();
// Update the chat's updatedAt timestamp
await this.chatEntity.update({ chatId }).set({}).go();
return message;
} catch (error) {
// ...
}
}
// ... implement more
}
ElectroDB creates items where, for example, a chat item has the PK CHAT#123
and SK METADATA
, while an associated
message has the PK CHAT#123
and SK MSG#2024-01-01T10:00:00Z#abc
. The GSI chat item would have, for instance, gsi1pk: USER#abc
and
gsi1sk: CHAT#2024-01-01T10:00:00Z
. All attributes (userId
, content
, etc.) are also stored within the item.
pk | sk | __edb_e | __edb_v | chatId | content | createdAt | gsi1pk | gsi1sk | messageId | name | role | updatedAt | userId |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
CHAT#07bcce62-... | METADATA | chat | 1 | 07bcce62-... | null | 2025-04-10T15:49:09Z | USER#c364d8e2-... | CHAT#2025-04-10T15:49:09Z | null | New Chat | null | 2025-04-10T15:49:09Z | c364d8e2-... |
CHAT#07bcce62-... | MSG#2025-04-10T15:49:09Z#e314636c-... | chatmessage | 1 | 07bcce62-... | "Hey Mister" | 2025-04-10T15:49:09Z | null | null | e314636c-... | null | user | 2025-04-10T15:49:09Z | c364d8e2-... |
CHAT#07bcce62-... | MSG#2025-04-10T15:50:15Z#a9f8e7d6-... | chatmessage | 1 | 07bcce62-... | "What is uuup?" | 2025-04-10T15:50:15Z | null | null | a9f8e7d6-... | null | assistant | 2025-04-10T15:50:15Z | c364d8e2-... |
Complete chats table with 1 chat and 2 chat messages.
With this setup, we leverage the strengths of Single-Table Design without having to manually manage the complexity of key generation.
Amazon DynamoDB is a highly scalable, managed NoSQL database that particularly excels in serverless AWS architectures. The advantages are clear: fast setup, automatic scaling, low-latency performance, and, in On-Demand mode, extremely low starting costs without the need for an expensive RDS Proxy for Lambda. This makes it ideal for hobby projects, startups, and modern applications.
The trade-off is a paradigm shift: The learning curve is steeper than with relational databases. You need to move away from the concept of JOINs and instead model in an Access Pattern-driven way, often using Single-Table Design (STD). This requires planning but is rewarded by the resulting performance and scalability. Tools like ElectroDB help master the complexity of STD and ensure type safety.
DynamoDB isn't a silver bullet, but it's an excellent choice when scalability, low operational costs, and seamless serverless integration are priorities. Those willing to embrace the design philosophy and align their data modeling accordingly will find DynamoDB a powerful and often cost-effective solution for many modern use cases on AWS.