AWS DynamoDB vs. RDS in Serverless Environments: A DynamoDB Case Study

From High RDS Costs to Near Zero with DynamoDB: A Detailed Case Study Using a Serverless LLM Chat Application.

Author Eugen Kochtyrew

Eugen Kochtyrew

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:

DynamoDB vs RDS Cost Optimization

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: The Serverless Database from AWS

What is DynamoDB?

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.

Why is it Ideal for Serverless (Especially with Lambda)?

DynamoDB shines in serverless architectures because:

  • Serverless by Design: Seamlessly integrates into the philosophy and architecture of serverless applications.
  • Seamless Scalability: Automatically adapts to any load, from zero to billions of requests.
  • True Pay-per-Use (On-Demand): Costs are incurred (almost) only for actual usage (read/write operations, storage). Perfect for variable loads and cost control – nearly free when idle.
  • Fast Setup & Performance: Tables are ready in minutes, accesses occur with single-digit millisecond latencies.

The Crux: DynamoDB vs. Relational Databases (RDS/Aurora) with Lambda

Especially in the Lambda context, DynamoDB solves typical problems faced by relational databases:

  • Connection Management: Lambdas can easily exceed the connection limit of relational databases. The solution (RDS Proxy) costs extra and increases complexity. This problem doesn't arise with DynamoDB in the first place.
  • Cold Start Latency: Establishing connections from "cold" Lambdas to RDS can be noticeably slow (often several seconds). DynamoDB requests are consistently fast.
  • Base Costs & Environments: RDS has fixed monthly costs per instance, even when idle. This multiplies across Dev/Staging/Prod environments. DynamoDB On-Demand only incurs significant costs with actual usage, making it ideal for infrequently used test environments.

DynamoDB: Key Advantages and Disadvantages

Choosing DynamoDB offers specific benefits, particularly in a serverless context, but also comes with challenges. The following table contrasts the most important points:

Advantages (Strengths)

✅ 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

Disadvantages (Challenges)

❌ 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)

☝️

Important Context for the "Disadvantages":

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.


Costs in Detail: DynamoDB vs. RDS/Aurora

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.

Pricing Model Basics (Briefly):

  • DynamoDB (On-Demand): Cost per read/write request + storage. Almost no cost when idle. Includes Free Tier.
  • RDS PostgreSQL: Cost per instance hour + storage + potentially I/O. Requires RDS Proxy for Lambda.
  • Aurora Serverless v2: Cost per ACU-hour (min 0.5 ACUs) + storage + I/O. Often requires RDS Proxy for 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.

Scenario Comparison (Estimates, as of 2025):

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.

Scenario 1: Startup / Hobby Project

  • 5 GB data storage, 300k requests per month
  • DynamoDB On-Demand, 0.5 ACU Aurora Serverless v2, RDS t3.micro

Comparison of estimated monthly costs for Scenario 1

Cost TypeDynamoDBAuroraRDS
Data Storage$0.00 (FT)$0.50$0.58
Instance/Capacity CostsN/A$43.80 (min 0.5 ACU)$12.41
Throughput Costs$0.79$0.00$0.00
RDS Proxy CostsN/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.

Scenario 2: Growing Startup (Scaleup)

  • 500 GB Data Storage, ~43 Million requests per month
  • DynamoDB On-Demand, Scaled Aurora Serverless v2, RDS t4g.medium

Comparison of estimated monthly costs for Scenario 2

Cost TypeDynamoDBAuroraRDS
Data Storage$118.75$50.00$57.50
Instance/Capacity Costsn/a$350.40 (scaled)$49.64
Throughput Costs$113.40$0.00$0.00
RDS Proxy Costsn/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.

Scenario 3: Established Scaleup

  • 5 TB Data Storage, ~432 Million requests per month
  • DynamoDB On-Demand, Scaled Aurora Serverless v2, RDS r5.2xlarge

Comparison of estimated monthly costs for Scenario 3

Cost TypeDynamoDBAuroraRDS
Data Storage$1,243.75$500.00$575.00
Instance/Capacity Costsn/a$1,401.60 (scaled)$730.00
Throughput Costs$1,134.00$0.00$0.00
RDS Proxy Costsn/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.

Optimization Potential under High Load (Scenario 3 Optimized)

For consistently high workloads, significant savings can be achieved through commitment (Reserved Instances/Capacity) and potentially caching (DAX):

*Examples for 1-Year Commitment, actual savings vary.

Cost ComponentDynamoDB (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/OIncluded 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.

Price Summary:

  1. Low/Variable Load: DynamoDB On-Demand is almost unbeatably cheap thanks to the Free Tier and pay-per-use model, as there are no fixed costs for instances/proxies.
  2. High/Constant Load: Optimized RDS is often the computationally cheapest option, but optimized DynamoDB is very competitive and significantly reduces management overhead.
  3. Multi-Environment: DynamoDB On-Demand has a clear advantage, as Dev/Staging environments incur minimal costs.

Pure costs are only part of the picture. Technical fit, scalability, development speed, and management effort must also factor into the decision.


DynamoDB in Practice

Goodbye JOINs, Hello Single-Table Design

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.

Why Single-Table? Because DynamoDB Doesn't Have JOINs (and that's a good thing!)

  • Performance: DynamoDB was optimized for extremely fast key-value access. JOINs, as known from SQL, are deliberately absent because they are expensive at runtime and limit scalability.
  • Access Pattern Focus: With STD, you don't primarily model the entities, but the access patterns: How will the application read and write data? You design primary keys (PK, SK) and secondary indexes (GSIs) so that related data often needed together (e.g., a chat and its messages) can be retrieved with a single, efficient query. You essentially "pre-join" the data during storage.
  • Costs: Fewer queries often mean lower costs.

The Trade-Off:

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.

Practical Implementation: Single-Table Design with TypeScript & 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:

  • Abstraction: Automatically generates complex PK/SK/GSI structures from your model.
  • Type Safety: Strong typing for entities and queries prevents errors.
  • Schema & Validation: Defines structure, rules, and default values for your data.
  • Simple Queries: Provides a clear API for CRUD operations.
  • Collections: Groups related entities (like Chat & Messages) for combined queries.

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.

The Data Model for Chat & Message (with ElectroDB)

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;

Key Concepts:

  • PK/SK Templates: Define how primary keys are built from attributes. Important: Chat and ChatMessage share the pk (CHAT#{"<chatId>"}), which groups them. They differ in the sk (METADATA vs. MSG#...).
  • GSI (byUser-Index): Enables an alternative access path (all chats for a user).
  • collection chatAndMessages: Allows retrieving a chat and its messages with a single query targeting the pk (CHAT#{"<chatId>"}).

Example Operations (DAL Usage)

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
}

The Result in DynamoDB:

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.

pksk__edb_e__edb_vchatIdcontentcreatedAtgsi1pkgsi1skmessageIdnameroleupdatedAtuserId
CHAT#07bcce62-...METADATAchat107bcce62-...

null

2025-04-10T15:49:09ZUSER#c364d8e2-...CHAT#2025-04-10T15:49:09Z

null

New Chat

null

2025-04-10T15:49:09Zc364d8e2-...
CHAT#07bcce62-...MSG#2025-04-10T15:49:09Z#e314636c-...chatmessage107bcce62-..."Hey Mister"2025-04-10T15:49:09Z

null

null

e314636c-...

null

user2025-04-10T15:49:09Zc364d8e2-...
CHAT#07bcce62-...MSG#2025-04-10T15:50:15Z#a9f8e7d6-...chatmessage107bcce62-..."What is uuup?"2025-04-10T15:50:15Z

null

null

a9f8e7d6-...

null

assistant2025-04-10T15:50:15Zc364d8e2-...

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.


Conclusion: DynamoDB – More Than Just a Database

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.