Back to Blog
Serverless7 min read

AWS Lambda: Serverless Patterns & Anti-Patterns

Explore serverless architecture with AWS Lambda, focusing on practical patterns and common anti-patterns. Learn from a full-stack developer's experience building real-world applications.

Jay Salot

Jay Salot

Sr. Full Stack Developer

April 17, 2026 · 7 min read

Share
Cloud computing and networking

AWS Lambda has become a cornerstone of modern serverless architectures. As a full-stack developer working primarily with JavaScript/TypeScript, I've spent a lot of time wrestling with Lambda, figuring out what works and what definitely doesn't. This post shares some of the patterns and anti-patterns I've encountered while building web applications using Lambda.

Synchronous vs. Asynchronous Patterns

One of the first decisions you'll face is whether your Lambda function should operate synchronously or asynchronously. This choice drastically impacts your application's architecture.

Synchronous Invocation (Request/Response)

In synchronous invocation, the caller waits for the Lambda function to execute and return a response. This is great for APIs where you need immediate feedback.

// Example: Synchronous Lambda function
exports.handler = async (event: any) => {
  try {
    const data = await fetchData(event.id);
    return {
      statusCode: 200,
      body: JSON.stringify(data),
    };
  } catch (error) {
    console.error(error);
    return {
      statusCode: 500,
      body: JSON.stringify({ message: 'Internal Server Error' }),
    };
  }
};

async function fetchData(id: string) {
  // Simulate fetching data from a database
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id: id, value: 'Some data' });
    }, 500);
  });
}

The caller (e.g., API Gateway) will wait for this function to complete before returning a response to the client. This is straightforward but can lead to performance bottlenecks if the Lambda function takes too long to execute.

Asynchronous Invocation (Event-Driven)

With asynchronous invocation, the caller doesn't wait for a response. Lambda queues the event and executes the function later. This is ideal for tasks that don't require immediate feedback, like processing images or sending emails.

// Example: Asynchronous Lambda function
exports.handler = async (event: any) => {
  try {
    await processData(event.data);
    console.log('Data processed successfully');
  } catch (error) {
    console.error('Error processing data:', error);
    // Handle error (e.g., retry, dead-letter queue)
  }
};

async function processData(data: any) {
  // Simulate processing data
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('Processing:', data);
      resolve(true);
    }, 1000);
  });
}

The key difference here is that the caller immediately receives a 202 Accepted response, and Lambda handles the execution in the background. This decouples the components and improves responsiveness.

Lambda Layers for Dependency Management

One of the biggest headaches with Lambda is managing dependencies. Without layers, you end up uploading the same libraries with every function. Lambda Layers let you package dependencies separately and share them across multiple functions.

Creating and Using Layers

To create a layer, you package your dependencies into a zip file with a specific directory structure (e.g., `nodejs/node_modules`). Then, you upload the zip file to Lambda and configure your functions to use the layer.

# Example: Creating a Lambda layer
mkdir -p nodejs/node_modules
pm install lodash --prefix nodejs
zip -r lodash-layer.zip nodejs
aws lambda publish-layer-version --layer-name lodash-layer --zip-file fileb://lodash-layer.zip --compatible-runtimes nodejs16 nodejs18

In your Lambda function, you can then import modules from the layer as if they were installed locally.

// Example: Using a Lambda layer
const _ = require('lodash');

exports.handler = async (event: any) => {
  const shuffled = _.shuffle([1, 2, 3, 4, 5]);
  return {
    statusCode: 200,
    body: JSON.stringify(shuffled),
  };
};

Gotcha: Be mindful of the size limits for Lambda packages and layers. Exceeding these limits can cause deployment failures.

Database Access Patterns

Accessing databases from Lambda functions requires careful consideration. Directly connecting to a database from every invocation can quickly exhaust connection limits.

Connection Pooling

Implementing connection pooling is crucial for efficient database access. Instead of creating a new connection for each invocation, you maintain a pool of active connections that can be reused.

// Example: Connection pooling with pg (PostgreSQL)
const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 10, // Maximum number of connections in the pool
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
});

exports.handler = async (event: any) => {
  let client;
  try {
    client = await pool.connect();
    const result = await client.query('SELECT NOW()');
    return {
      statusCode: 200,
      body: JSON.stringify(result.rows[0]),
    };
  } catch (error) {
    console.error(error);
    return {
      statusCode: 500,
      body: JSON.stringify({ message: 'Database error' }),
    };
  } finally {
    if (client) {
      client.release(); // Release the connection back to the pool
    }
  }
};

This approach significantly reduces the overhead of establishing new connections for each invocation.

Data API Proxy

Another pattern is to use a dedicated API proxy (e.g., using API Gateway and another Lambda function) to handle database interactions. This centralizes database access logic and can improve security and performance.

Error Handling and Retries

Robust error handling is essential for serverless applications. Lambda provides built-in retry mechanisms, but you often need to implement custom logic to handle specific errors.

Dead-Letter Queues (DLQ)

Configure Dead-Letter Queues (DLQs) for asynchronous invocations. When a Lambda function fails repeatedly, the event is sent to the DLQ for further investigation. This prevents failed events from being lost.

// Example: Setting up a DLQ (using SQS)
// In your Lambda function configuration:
// - Configure the DLQ ARN to point to your SQS queue.

exports.handler = async (event: any) => {
  try {
    // Your logic here
    throw new Error('Something went wrong'); // Simulate an error
  } catch (error) {
    console.error('Error:', error);
    // No need to explicitly send to DLQ, Lambda handles it automatically
    throw error; // Re-throw the error to trigger retry/DLQ
  }
};

Note: Lambda automatically retries asynchronous invocations a few times before sending the event to the DLQ.

Idempotency

Ensure your Lambda functions are idempotent, especially when dealing with critical operations. Idempotency means that executing the function multiple times with the same input has the same effect as executing it once. This is important for handling retries and preventing duplicate processing.

Common Anti-Patterns

Let's talk about what *not* to do. I've seen these mistakes repeated, and they always lead to problems.

Monolithic Lambdas

Avoid creating large, monolithic Lambda functions that handle multiple responsibilities. This makes them harder to maintain and debug. Instead, break them down into smaller, single-purpose functions.

Over-Reliance on Environment Variables

While environment variables are useful for configuration, avoid storing sensitive information or complex logic in them. Use secrets management services (e.g., AWS Secrets Manager) for sensitive data and externalize complex logic into separate modules.

Ignoring Cold Starts

Cold starts can significantly impact the performance of your Lambda functions. When a Lambda function is invoked for the first time or after a period of inactivity, it takes time to initialize the execution environment. Keep your function code small, optimize dependencies, and consider using provisioned concurrency for latency-sensitive applications.

Monitoring and Logging

Effective monitoring and logging are crucial for understanding the behavior of your Lambda functions and troubleshooting issues.

CloudWatch Logs

Use CloudWatch Logs to capture logs from your Lambda functions. Implement structured logging to make it easier to search and analyze your logs.

// Example: Structured logging
exports.handler = async (event: any) => {
  console.log(JSON.stringify({
    level: 'info',
    message: 'Lambda function invoked',
    eventId: event.id,
    timestamp: new Date().toISOString(),
  }));

  return {
    statusCode: 200,
    body: JSON.stringify({ message: 'Success' }),
  };
};

CloudWatch Metrics

Use CloudWatch Metrics to monitor key performance indicators (KPIs) such as invocation count, error rate, and duration. Set up alarms to be notified of potential issues.

Conclusion: Key Takeaways

AWS Lambda offers a powerful way to build serverless applications, but it's essential to understand the patterns and anti-patterns involved. By choosing the right invocation type, managing dependencies effectively, handling errors gracefully, and avoiding common pitfalls, you can build scalable, reliable, and cost-effective serverless solutions. I've learned a lot of these lessons the hard way, and hopefully, this post can help you avoid some of the same mistakes. Remember to monitor your functions closely and iterate based on real-world performance data. Good luck!

#Lambda#serverless#architecture#patterns#anti-patterns#AWS#JavaScript
Share

Related Articles