Building Scalable SaaS with AWS Serverless: Lambda, Cognito, API Gateway, and MongoDB

February 19, 2026 (3 days ago)

When building a multi-tenant SaaS platform, the architecture decisions you make early determine whether you'll be firefighting at 3am or sleeping peacefully while your system handles traffic spikes automatically.

After building several production SaaS platforms—including an HRMS system handling enterprise clients—I've settled on a stack that balances developer experience, operational simplicity, and cost efficiency: AWS Lambda, Cognito, API Gateway, MongoDB, and the SNS/SQS/SES trio for async operations.

Here's why this combination works so well together.

The Core: API Gateway + Lambda

API Gateway acts as the front door to your application. It handles SSL termination, request throttling, and routing—all managed infrastructure you don't need to maintain.

Behind it, Lambda functions execute your business logic. The key insight is that Lambda isn't just "functions as a service"—it's a deployment model that eliminates capacity planning.

Client Request
    
API Gateway (throttling, routing, SSL)
    
Lambda Authorizer (JWT validation)
    
Lambda Function (business logic)
    
MongoDB (data persistence)

In practice, I deploy NestJS applications to Lambda using a single handler that bootstraps the entire framework. This gives you the structure of a traditional backend with the scaling benefits of serverless:

import { NestFactory } from '@nestjs/core'
import { ExpressAdapter } from '@nestjs/platform-express'
import serverlessExpress from '@codegenie/serverless-express'
import express from 'express'
import { AppModule } from './app.module'

let cachedServer: any

async function bootstrap() {
  if (cachedServer) return cachedServer
  
  const expressApp = express()
  const adapter = new ExpressAdapter(expressApp)
  const app = await NestFactory.create(AppModule, adapter)
  
  await app.init()
  cachedServer = serverlessExpress({ app: expressApp })
  return cachedServer
}

export const handler = async (event, context) => {
  const server = await bootstrap()
  return server(event, context)
}

Cold starts? With proper bundling and connection pooling, they're typically under 500ms. For most SaaS applications, this is acceptable.

Authentication: Cognito

Cognito handles the authentication layer—user registration, login, password reset, MFA, and JWT token management. You're not writing password hashing code or managing refresh token rotation.

The integration pattern I use:

  1. Cognito User Pool manages user credentials
  2. API Gateway uses a Lambda Authorizer to validate JWTs
  3. The authorizer extracts user claims and passes them to downstream functions
// Lambda Authorizer - validates Cognito JWT
export const handler = async (event) => {
  const token = extractToken(event.headers.authorization)
  
  const decoded = await verifyToken(token, {
    userPoolId: process.env.COGNITO_USER_POOL_ID,
    clientId: process.env.COGNITO_CLIENT_ID,
  })
  
  return {
    principalId: decoded.sub,
    policyDocument: generatePolicy('Allow', event.methodArn),
    context: {
      userId: decoded.sub,
      email: decoded.email,
      workspaceId: decoded['custom:workspace_id'],
    },
  }
}

For multi-tenant SaaS, I store workspace associations in Cognito custom attributes or in a separate authorization layer that maps users to workspaces with roles.

Data Layer: MongoDB

MongoDB pairs well with serverless for several reasons:

  • Schema flexibility matches the iterative nature of SaaS development
  • MongoDB Atlas handles replication, backups, and scaling
  • Connection pooling works with Lambda's execution model (with proper configuration)

The multi-tenancy pattern I use is workspace-scoped queries:

// Every query includes workspaceId
async findApplications(workspaceId: string, filters: QueryDto) {
  return this.model.find({
    workspaceId,
    ...this.buildFilters(filters),
  })
}

This keeps tenant data isolated without the complexity of separate databases per tenant.

For Lambda, the key is connection management. MongoDB connections should be established outside the handler and reused across invocations:

let cachedConnection: Connection | null = null

async function getConnection(): Promise<Connection> {
  if (cachedConnection) return cachedConnection
  
  cachedConnection = await mongoose.createConnection(process.env.MONGODB_URI, {
    maxPoolSize: 10,
    serverSelectionTimeoutMS: 5000,
  })
  
  return cachedConnection
}

Async Operations: SNS, SQS, and SES

Synchronous request-response works for most operations, but some tasks shouldn't block the user: sending emails, processing uploads, generating reports, triggering webhooks.

This is where SNS, SQS, and SES shine.

The pattern:

User Action (e.g., submit application)
    
Lambda publishes event to SNS
    
SNS fans out to multiple SQS queues
    
Worker Lambdas process each queue
    
SES sends transactional emails

SNS (Simple Notification Service) acts as the event bus. When something happens in your system, publish an event:

await sns.publish({
  TopicArn: process.env.EVENTS_TOPIC_ARN,
  Message: JSON.stringify({
    type: 'APPLICATION_SUBMITTED',
    payload: {
      applicationId,
      candidateEmail,
      jobTitle,
      workspaceId,
    },
  }),
})

SQS (Simple Queue Service) provides durable queues that buffer events. If your worker Lambda fails, the message goes back to the queue and retries. Dead letter queues catch messages that fail repeatedly.

SES (Simple Email Service) handles transactional email delivery. Combined with SQS, you get reliable email delivery with automatic retries:

// Worker Lambda triggered by SQS
export const handler = async (event) => {
  for (const record of event.Records) {
    const message = JSON.parse(record.body)
    
    if (message.type === 'APPLICATION_SUBMITTED') {
      await ses.sendTemplatedEmail({
        Source: 'notifications@yourapp.com',
        Destination: { ToAddresses: [message.payload.candidateEmail] },
        Template: 'ApplicationReceived',
        TemplateData: JSON.stringify({
          jobTitle: message.payload.jobTitle,
        }),
      })
    }
  }
}

Why This Stack Works

Cost efficiency: You pay for actual usage. A SaaS platform with variable traffic doesn't need provisioned servers sitting idle.

Operational simplicity: No servers to patch, no capacity to plan. AWS handles the infrastructure.

Scaling: Lambda scales automatically. MongoDB Atlas scales with your data. SQS absorbs traffic spikes.

Developer experience: You write application code, not infrastructure code. NestJS gives you structure; Lambda gives you deployment.

Reliability: SQS provides durability for async operations. Dead letter queues catch failures. CloudWatch gives you observability.

The Trade-offs

This architecture isn't perfect for everything:

  • Cold starts matter for latency-sensitive applications
  • Long-running processes don't fit Lambda's 15-minute limit
  • WebSocket connections require additional infrastructure (API Gateway WebSocket APIs)
  • Local development needs tools like SAM or serverless-offline

For most SaaS applications—especially B2B platforms where sub-second latency isn't critical—these trade-offs are acceptable.

Conclusion

The combination of Lambda, Cognito, API Gateway, MongoDB, and SNS/SQS/SES provides a foundation that scales from zero to millions of requests without architectural changes.

You focus on building features. AWS handles the infrastructure.

That's the real power of this stack—not any single service, but how they compose together into a system that's greater than the sum of its parts.