Skip to content

HTTP API

Event handler for Amazon API Gateway REST and HTTP APIs, Application Loader Balancer (ALB), and Lambda Function URLs.

Key Features

  • Lightweight routing to reduce boilerplate for API Gateway REST/HTTP API, ALB and Lambda Function URLs.
  • Built-in middleware engine for request/response transformation (validation coming soon).
  • Works with micro function (one or a few routes) and monolithic functions (see Considerations).

Getting started

Install

1
npm install @aws-lambda-powertools/event-handler

Required resources

The event handler works with different types of events. It can process events from API Gateway REST APIs, HTTP APIs, ALB, Lambda Function URLs, and will soon support VPC Lattice as well.

You must have an existing integration configured to invoke your Lambda function depending on what you are using:

Integration Documentation
API Gateway REST API Proxy integration
API Gateway HTTP API Proxy integration
Application Load Balancer ALB configuration
Lambda Function URL Function URL configuration

This is the sample infrastructure for the different integrations we are using for the examples in this documentation. There is no additional permissions or dependencies required to use this utility.

See Infrastructure as Code (IaC) examples
AWS Serverless Application Model (SAM) example
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Hello world event handler API Gateway REST API

Globals:
  Api:
    TracingEnabled: true
    Cors: # see CORS section
      AllowOrigin: "'https://example.com'"
      AllowHeaders: "'Content-Type,Authorization,X-Amz-Date'"
      MaxAge: "'300'"
    BinaryMediaTypes: # see Binary responses section
      - "*~1*" # converts to */* for any binary type
      # NOTE: use this stricter version if you're also using CORS; */* doesn't work with CORS
      # see: https://github.com/aws-powertools/powertools-lambda-python/issues/3373#issuecomment-1821144779
      # - "image~1*" # converts to image/*
      # - "*~1csv" # converts to */csv, eg text/csv, application/csv

  Function:
    Timeout: 5
    MemorySize: 256
    Runtime: nodejs24.x
    Tracing: Active
    Environment:
      Variables:
        POWERTOOLS_LOG_LEVEL: INFO
        POWERTOOLS_SERVICE_NAME: hello

Resources:
  ApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      CodeUri: hello_world
      Description: API handler function
      Events:
        AnyApiEvent:
          Type: Api
          Properties:
            # NOTE: this is a catch-all rule to simplify the documentation.
            # explicit routes and methods are recommended for prod instead (see below)
            Path: /{proxy+} # Send requests on any path to the lambda function
            Method: ANY # Send requests using any http method to the lambda function
        GetAllTodos:
           Type: Api
           Properties:
             Path: /todos
             Method: GET
        GetTodoById:
           Type: Api
           Properties:
             Path: /todos/{todo_id}
             Method: GET
AWS Serverless Application Model (SAM) example
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Hello world event handler API Gateway HTTP API

Globals:
  HttpApi:
    CorsConfiguration:
      AllowOrigins:
        - https://example.com
      AllowHeaders:
        - Content-Type
        - Authorization
        - X-Amz-Date
      MaxAge: 300

  Function:
    Timeout: 5
    MemorySize: 256
    Runtime: nodejs22.x
    Tracing: Active
    Environment:
      Variables:
        POWERTOOLS_LOG_LEVEL: INFO
        POWERTOOLS_SERVICE_NAME: hello

Resources:
  ApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      CodeUri: hello_world
      Description: API handler function
      Events:
        AnyApiEvent:
          Type: HttpApi
          Properties:
            Path: /{proxy+}
            Method: ANY
        GetAllTodos:
           Type: HttpApi
           Properties:
             Path: /todos
             Method: GET
        GetTodoById:
           Type: HttpApi
           Properties:
             Path: /todos/{todo_id}
             Method: GET
AWS Serverless Application Model (SAM) example
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Hello world event handler Lambda Function URL

Globals:
  Function:
    Timeout: 5
    MemorySize: 256
    Runtime: nodejs24.x
    Tracing: Active
    Environment:
      Variables:
        POWERTOOLS_LOG_LEVEL: INFO
        POWERTOOLS_SERVICE_NAME: hello
    FunctionUrlConfig:
      Cors: # see CORS section
        # Notice that values here are Lists of Strings, vs comma-separated values on API Gateway
        AllowOrigins: ["https://example.com"]
        AllowHeaders: ["Content-Type", "Authorization", "X-Amz-Date"]
        MaxAge: 300

Resources:
  ApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      CodeUri: hello_world
      Description: API handler function
      FunctionUrlConfig:
        AuthType: NONE # AWS_IAM for added security beyond sample documentation

Route events

When a request is received, the event handler automatically detects the event type and converts it into a Request object.

You get access to headers, query parameters, request body, and path parameters via typed arguments. The response type is determined automatically based on the event.

Response auto-serialization

Want full control over the response, headers, and status code? Read how to return Response objects directly.

For your convenience, when you return a JavaScript object from your route handler, we automatically perform these actions:

  • Auto-serialize the response to JSON
  • Include the response under the appropriate equivalent of a body
  • Set the Content-Type header to application/json
  • Set the HTTP status code to 200 (OK)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import { Router } from '@aws-lambda-powertools/event-handler/http';
import type { Context } from 'aws-lambda';

const app = new Router();

app.get('/ping', async () => {
  return { message: 'pong' }; // (1)!
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);
  1. This object will be serialized and included under the body key
1
2
3
4
5
6
7
8
{
  "statusCode": 200,
  "headers": {
    "Content-Type": "application/json"
  },
  "body": "{'message':'pong'}",
  "isBase64Encoded": false
}

Automatic response format transformation

The event handler automatically ensures the correct response format is returned based on the event type received. For example, if your handler returns an API Gateway v1 proxy response but processes an ALB event, we'll automatically transform it into an ALB-compatible response. This allows you to swap integrations with little to no code changes.

Dynamic routes

You can use /todos/:todoId to configure dynamic URL paths, where :todoId will be resolved at runtime.

All dynamic route parameters will be available as typed object properties in the first argument of your route handler.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Router } from '@aws-lambda-powertools/event-handler/http';
import { Logger } from '@aws-lambda-powertools/logger';
import {
  correlationPaths,
  search,
} from '@aws-lambda-powertools/logger/correlationId';
import type { Context } from 'aws-lambda/handler';

const logger = new Logger({
  correlationIdSearchFn: search,
});
const app = new Router({ logger });

app.get('/todos/:todoId', async ({ params: { todoId } }) => {
  const todo = await getTodoById(todoId);
  return { todo };
});

export const handler = async (event: unknown, context: Context) => {
  // You can continue using other utilities just as before
  logger.addContext(context);
  logger.setCorrelationId(event, correlationPaths.API_GATEWAY_REST);
  return app.resolve(event, context);
};
1
2
3
4
5
{
  "resource": "/todos/{id}",
  "path": "/todos/1",
  "httpMethod": "GET"
}

You can also nest dynamic paths, for example /todos/:todoId/comments/:commentId, where both :todoId and :commentId will be resolved at runtime.

Catch-all routes

For scenarios where you need to handle arbitrary or deeply nested paths, you can use regex patterns directly in your route definitions. These are particularly useful for proxy routes or when dealing with file paths.

We recommend having explicit routes whenever possible; use catch-all routes sparingly.

Using Regex Patterns

You can use standard regular expressions in your route definitions, for example:

Pattern Description Examples
/.+/ Matches one or more characters (greedy) /\/proxy\/.+/ matches /proxy/any/deep/path
/.*/ Matches zero or more characters (greedy) /\/files\/.*/ matches /files/ and /files/deep/path
/[^/]+/ Matches one or more non-slash characters /\/api\/[^\/]+/ matches /api/v1 but not /api/v1/users
/\w+/ Matches one or more word characters /\/users\/\w+/ matches /users/john123
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Router } from '@aws-lambda-powertools/event-handler/http';
import type { APIGatewayProxyEvent, Context } from 'aws-lambda';

const app = new Router();

// File path proxy
app.get(/\/files\/.+/, () => 'Catch any GET method under /files');

// API versioning with any format
app.get(/\/api\/v\d+\/.*/, () => 'Catch any GET method under /api/vX');

// Mixed: dynamic parameter + regex catch-all
app.get(/\/users\/:userId\/files\/.+/, (reqCtx) => {
  return {
    userId: reqCtx.params.userId,
  };
});

// Catch all route
app.get(/.+/, () => 'Catch any GET method');

export const handler = async (event: APIGatewayProxyEvent, context: Context) =>
  app.resolve(event, context);
Route Matching Priority
  • For non-regex routes, routes are matched in order of specificity, not registration order
  • More specific routes (exact matches) take precedence over regex patterns
  • Among regex routes, registration order determines matching precedence, therefore, always place catch-all routes /.*/ last

HTTP Methods

You can use dedicated methods to specify the HTTP method that should be handled in each resolver. That is, app.<httpMethod>(), where the HTTP method could be delete, get, head, patch, post, put, options.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { Router } from '@aws-lambda-powertools/event-handler/http';
import { Logger } from '@aws-lambda-powertools/logger';
import {
  correlationPaths,
  search,
} from '@aws-lambda-powertools/logger/correlationId';
import type { Context } from 'aws-lambda/handler';

const logger = new Logger({
  correlationIdSearchFn: search,
});
const app = new Router({ logger });

app.post('/todos', async ({ req }) => {
  const body = await req.json();
  const todo = await putTodo(body);

  return todo;
});

export const handler = async (event: unknown, context: Context) => {
  // You can continue using other utilities just as before
  logger.addContext(context);
  logger.setCorrelationId(event, correlationPaths.API_GATEWAY_REST);
  return app.resolve(event, context);
};
1
2
3
4
5
6
{
  "resource": "/todos",
  "path": "/todos",
  "httpMethod": "POST",
  "body": "{\"title\": \"foo\", \"userId\": 1, \"completed\": false}"
}

If you need to accept multiple HTTP methods in a single function, or support an HTTP method for which no dedicated method exists (i.e. TRACE), you can use the route() method and pass a list of HTTP methods.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { Router } from '@aws-lambda-powertools/event-handler/http';
import { Logger } from '@aws-lambda-powertools/logger';
import {
  correlationPaths,
  search,
} from '@aws-lambda-powertools/logger/correlationId';
import type { Context } from 'aws-lambda/handler';

const logger = new Logger({
  correlationIdSearchFn: search,
});
const app = new Router({ logger });

app.route(
  async ({ req }) => {
    const body = await req.json();
    const todo = await putTodo(body);

    return todo;
  },
  {
    path: '/todos',
    method: ['POST', 'PUT'],
  }
);

export const handler = async (event: unknown, context: Context) => {
  // You can continue using other utilities just as before
  logger.addContext(context);
  logger.setCorrelationId(event, correlationPaths.API_GATEWAY_REST);
  return app.resolve(event, context);
};

Tip

We recommend defining separate route handlers for each HTTP method within your Lambda function, as the functionality typically differs between operations such as GET, POST, PUT, DELETE etc

Data validation

Coming soon

We plan to add built-in support for request and response validation using Standard Schema in a future release. For the time being, you can use any validation library of your choice in your route handlers or middleware.

Please check this issue for more details and examples, and add 👍 if you would like us to prioritize it.

Accessing request details

You can access request details such as headers, query parameters, and body using the Request object provided to your route handlers and middleware functions via reqCtx.req.

Error handling

You can use the errorHandler() method as a higher-order function or class method decorator to define a custom error handler for errors thrown in your route handlers or middleware.

This allows you to catch and return custom error responses, or perform any other error handling logic you need.

Error handlers receive the error object and the request context as arguments, and can return a Response object or a JavaScript object that will be auto-serialized as per the response auto-serialization section.

You can also pass a list of error classes to the errorHandler() method.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import {
  HttpStatusCodes,
  Router,
} from '@aws-lambda-powertools/event-handler/http';
import { Logger } from '@aws-lambda-powertools/logger';
import type { Context } from 'aws-lambda/handler';

const logger = new Logger();
const app = new Router({ logger });

app.errorHandler(GetTodoError, async (error, reqCtx) => {
  logger.error('Unable to get todo', { error });

  return {
    statusCode: HttpStatusCodes.BAD_REQUEST,
    message: `Bad request: ${error.message} - ${reqCtx.req.headers.get('x-correlation-id')}`,
  };
});

app.get('/todos/:todoId', async ({ params: { todoId } }) => {
  const todo = await getTodoById(todoId); // May throw GetTodoError
  return { todo };
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);

Built-in Error Handlers

We provide built-in error handlers for common routing errors so you don't have to specify the Error type explicitly.

You can use the notFound() and methodNotAllowed() methods as higher-order functions or class method decorators to customize error responses for unmatched routes and unsupported HTTP methods.

By default, we return a 404 Not Found response for unmatched routes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import {
  HttpStatusCodes,
  Router,
} from '@aws-lambda-powertools/event-handler/http';
import { Logger } from '@aws-lambda-powertools/logger';
import type { Context } from 'aws-lambda/handler';

const logger = new Logger();
const app = new Router({ logger });

app.notFound(async (error, reqCtx) => {
  logger.error('Unable to get todo', { error });

  return {
    statusCode: HttpStatusCodes.IM_A_TEAPOT,
    body: "I'm a teapot!",
    headers: {
      'x-correlation-id': reqCtx.req.headers.get('x-correlation-id'),
    },
  };
});

app.methodNotAllowed(async (error) => {
  logger.error('Method not allowed', { error });

  return {
    body: 'This method is not allowed',
  };
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);

Throwing HTTP errors

You can throw HTTP errors in your route handlers to stop execution and return specific HTTP status codes and messages. Event Handler provides a set of built-in HTTP error classes that you can use to throw common HTTP errors.

This ensures that your Lambda function doesn't fail but returns a well-defined HTTP error response to the client.

If you need to send custom headers or a different response structure/code, you can use the Response object instead.

You can throw HTTP errors in your route handlers, middleware, or custom error handlers!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import {
  Router,
  UnauthorizedError,
} from '@aws-lambda-powertools/event-handler/http';
import type { Context } from 'aws-lambda';

const app = new Router();

app.use(async ({ reqCtx, next }) => {
  if (!isAuthenticated(reqCtx.req.headers.get('Authorization') ?? '')) {
    throw new UnauthorizedError(); // This will return a 401 Unauthorized response
  }
  await next();
});

app.get('/secure', async () => {
  return { message: 'super important data' };
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);

Available HTTP error classes

The following HTTP error classes are available for use in your route handlers:

Error Class HTTP Status Code Description
BadRequestError 400 Bad Request - The request cannot be fulfilled due to bad syntax
UnauthorizedError 401 Unauthorized - Authentication is required and has failed or not been provided
ForbiddenError 403 Forbidden - The request is valid but the server is refusing action
NotFoundError 404 Not Found - The requested resource could not be found
MethodNotAllowedError 405 Method Not Allowed - The request method is not supported for the requested resource
RequestTimeoutError 408 Request Timeout - The server timed out waiting for the request
RequestEntityTooLargeError 413 Request Entity Too Large - The request is larger than the server is willing to process
InternalServerError 500 Internal Server Error - A generic error message for unexpected server conditions
ServiceUnavailableError 503 Service Unavailable - The server is currently unavailable

All error classes accept optional parameters for custom messages and additional details:

  • message - Custom error message
  • options - Standard JavaScript ErrorOptions
  • details - Additional structured data to include in the error response

Route prefixes

When defining multiple routes related to a specific resource, it's common to have a shared prefix. For example, you might have several routes that all start with /todos.

For example, if you have a custom domain api.example.com and you want to map it to the /v1 base path of your API. In this case, all the requests will contain /v1/<resource> in the path, requiring you to repeat the /v1 prefix in all your route definitions.

To avoid repeating the prefix in each route definition, you can use the prefix constructor parameter when creating a new Router instance, and we'll automatically strip it from the request path before matching routes. After mapping a path prefix, the new root path will automatically be mapped to the path argument of /.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { Router } from '@aws-lambda-powertools/event-handler/http';
import type { Context } from 'aws-lambda';

const app = new Router({ prefix: '/todos' });

// matches POST /todos
app.post('/', async ({ req: { headers } }) => {
  const todos = await getUserTodos(headers.get('Authorization'));
  return { todos };
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);

This is also useful when splitting routes into separate files (see Split routers section) or when using API mappings to map custom domains to specific base paths.

For example, when using prefix: '/pay', there is no difference between a request path of /pay and /pay/; and the path argument would be defined as /.

Advanced

Middleware

Middleware are functions that execute during the request-response cycle, sitting between the incoming request and your route handler. They provide a way to implement cross-cutting concerns like authentication, logging, validation, and response transformation without cluttering your route handlers.

Each middleware function receives two arguments:

  • reqCtx - Request context containing the event, Lambda context, request, and response objects
  • next - A function to pass control to the next middleware in the chain

Middleware can be applied on specific routes, globally on all routes, or a combination of both.

Middleware execution follows an onion pattern where global middleware runs first in pre-processing, then route-specific middleware. After the handler executes, the order reverses for post-processing. When middleware modify the same response properties, the middleware that executes last in post-processing wins.

sequenceDiagram
    participant Request
    participant Router
    participant GM as Global Middleware
    participant RM as Route Middleware
    participant Handler as Route Handler

    Request->>Router: Incoming Request
    Router->>GM: Execute ({ reqCtx, next })
    Note over GM: Pre-processing
    GM->>RM: Call await next()
    Note over RM: Pre-processing
    RM->>Handler: Call await next()
    Note over Handler: Execute handler
    Handler-->>RM: Return
    Note over RM: Post-processing
    RM-->>GM: Return
    Note over GM: Post-processing
    GM-->>Router: Return
    Router-->>Request: Response

Registering middleware

You can use app.use() to register middleware that should always run regardless of the route and you can apply middleware to specific routes by passing them as arguments before the route handler.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { Router } from '@aws-lambda-powertools/event-handler/http';
import type { Middleware } from '@aws-lambda-powertools/event-handler/types';
import { Logger } from '@aws-lambda-powertools/logger';
import type { Context } from 'aws-lambda';

const logger = new Logger();
const app = new Router({ logger });

// Global middleware - executes first in pre-processing, last in post-processing
app.use(async ({ reqCtx, next }) => {
  reqCtx.res.headers.set('x-pre-processed-by', 'global-middleware');
  await next();
  reqCtx.res.headers.set('x-post-processed-by', 'global-middleware');
});

// Route-specific middleware - executes second in pre-processing, first in post-processing
const routeMiddleware: Middleware = async ({ reqCtx, next }) => {
  reqCtx.res.headers.set('x-pre-processed-by', 'route-middleware');
  await next();
  reqCtx.res.headers.set('x-post-processed-by', 'route-middleware');
};

app.get('/todos', async () => {
  const todos = await getAllTodos();
  return { todos };
});

// This route will have:
// x-pre-processed-by: route-middleware (route middleware overwrites global)
// x-post-processed-by: global-middleware (global middleware executes last)
app.post('/todos', [routeMiddleware], async ({ req }) => {
  const body = await req.json();
  const todo = await putTodo(body);
  return todo;
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "statusCode": 200,
  "body": "{\"id\":\"123\",\"title\":\"New todo\"}",
  "headers": {
    "content-type": "application/json",
    "x-pre-processed-by": "route-middleware",
    "x-post-processed-by": "global-middleware"
  },
  "isBase64Encoded": false
}

Returning early

There are cases where you may want to terminate the execution of the middleware chain early. To do so, middleware can short-circuit processing by returning a Response or JSON object instead of calling await next().

Neither the handler nor any subsequent middleware will run but the post-processing of already executed middleware will.

sequenceDiagram
    participant Request
    participant Router
    participant M1 as Middleware 1
    participant M2 as Middleware 2
    participant M3 as Middleware 3
    participant Handler as Route Handler

    Request->>Router: Incoming Request
    Router->>M1: Execute ({ reqCtx, next })
    Note over M1: Pre-processing
    M1->>M2: Call await next()
    Note over M2: Pre-processing
    M2->>M2: Return Response (early return)
    Note over M2: Post-processing
    M2-->>M1: Return Response
    Note over M1: Post-processing
    M1-->>Router: Return Response
    Router-->>Request: Response
    Note over M3,Handler: Never executed
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { Router } from '@aws-lambda-powertools/event-handler/http';
import type { Middleware } from '@aws-lambda-powertools/event-handler/types';
import { Logger } from '@aws-lambda-powertools/logger';
import type { Context } from 'aws-lambda';

const logger = new Logger();
const app = new Router({ logger });

// Authentication middleware - returns early if no auth header
const authMiddleware: Middleware = async ({ reqCtx, next }) => {
  const authHeader = reqCtx.req.headers.get('authorization');

  if (!authHeader) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), {
      status: 401,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  await next();
};

// Logging middleware - never executes when auth fails
const loggingMiddleware: Middleware = async ({ next }) => {
  logger.info('Request processed');
  await next();
};

app.use(authMiddleware);
app.use(loggingMiddleware);

app.get('/todos', async () => {
  const todos = await getAllTodos();
  return { todos };
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);
1
2
3
4
5
6
7
8
{
  "statusCode": 401,
  "body": "{\"error\":\"Unauthorized\"}",
  "headers": {
    "Content-Type": "application/json"
  },
  "isBase64Encoded": false
}

Error Handling

By default, any unhandled error in the middleware chain will be propagated as a HTTP 500 back to the client. As you would expect, unlike early return, this stops the middleware chain entirely and no post-processing steps for any previously executed middleware will occur.

sequenceDiagram
    participant Request
    participant Router
    participant EH as Error Handler
    participant M1 as Middleware 1
    participant M2 as Middleware 2
    participant Handler as Route Handler

    Request->>Router: Incoming Request
    Router->>M1: Execute ({ reqCtx, next })
    Note over M1: Pre-processing
    M1->>M2: Call await next()
    Note over M2: Throws Error
    M2-->>M1: Error propagated
    M1-->>Router: Error propagated
    Router->>EH: Handle error
    EH-->>Router: HTTP 500 Response
    Router-->>Request: HTTP 500 Error
    Note over Handler: Never executed

Unhandled errors

You can handle errors in middleware as you would anywhere else, simply surround your code in a try/catch block and processing will occur as usual.

sequenceDiagram
    participant Request
    participant Router
    participant M1 as Middleware 1
    participant M2 as Middleware 2
    participant Handler as Route Handler

    Request->>Router: Incoming Request
    Router->>M1: Execute ({ reqCtx, next })
    Note over M1: Pre-processing
    M1->>M2: Call await next()
    Note over M2: Error thrown & caught
    Note over M2: Handle error gracefully
    M2->>Handler: Call await next()
    Note over Handler: Execute handler
    Handler-->>M2: Return
    Note over M2: Post-processing
    M2-->>M1: Return
    Note over M1: Post-processing
    M1-->>Router: Return
    Router-->>Request: Response

Handled errors

Similarly, you can choose to stop processing entirely by throwing an error in your middleware. Event handler provides many built-in HTTP errors that you can use or you can throw a custom error of your own. As noted above, this means that no post-processing of your request will occur.

sequenceDiagram
    participant Request
    participant Router
    participant EH as Error Handler
    participant M1 as Middleware 1
    participant M2 as Middleware 2
    participant Handler as Route Handler

    Request->>Router: Incoming Request
    Router->>M1: Execute ({ reqCtx, next })
    Note over M1: Pre-processing
    M1->>M2: Call await next()
    Note over M2: Intentionally throws error
    M2-->>M1: Error propagated
    M1-->>Router: Error propagated
    Router->>EH: Handle error
    EH-->>Router: HTTP Error Response
    Router-->>Request: HTTP Error Response
    Note over Handler: Never executed

Intentional errors

Custom middleware

A common pattern to create reusable middleware is to implement a factory functions that accepts configuration options and returns a middleware function.

Always await next() unless returning early

Middleware functions must always call await next() to pass control to the next middleware in the chain, unless you are intentionally returning early by returning a Response or JSON object.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import { getStringFromEnv } from '@aws-lambda-powertools/commons/utils/env';
import {
  Router,
  UnauthorizedError,
} from '@aws-lambda-powertools/event-handler/http';
import type { Middleware } from '@aws-lambda-powertools/event-handler/types';
import { Logger } from '@aws-lambda-powertools/logger';
import type { Context } from 'aws-lambda';

const jwtSecret = getStringFromEnv({
  key: 'JWT_SECRET',
  errorMessage: 'JWT_SECRET is not set',
});

const logger = new Logger({});
const app = new Router();
const store: { userId: string; roles: string[] } = { userId: '', roles: [] };

// Factory function that returns middleware
const verifyToken = (options: { jwtSecret: string }): Middleware => {
  return async ({ reqCtx: { req }, next }) => {
    const auth = req.headers.get('Authorization');
    if (!auth || !auth.startsWith('Bearer '))
      throw new UnauthorizedError('Missing or invalid Authorization header');

    const token = auth.slice(7);
    try {
      const payload = jwt.verify(token, options.jwtSecret);
      store.userId = payload.sub;
      store.roles = payload.roles;
    } catch (error) {
      logger.error('Token verification failed', { error });
      throw new UnauthorizedError('Invalid token');
    }

    await next();
  };
};

// Use custom middleware globally
app.use(verifyToken({ jwtSecret }));

app.post('/todos', async () => {
  const { userId } = store;
  const todos = await getUserTodos(userId);
  return { todos };
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);

In this example we have a middleware that acts only in the post-processing stage as all the logic occurs after the next function has been called. This is so as to ensure that the handler has run and we have access to request body.

Avoiding destructuring pitfalls

Never destructure the response object

When writing middleware, always access the response through reqCtx.res rather than destructuring { res } from the request context. Destructuring captures a reference to the original response object, which becomes stale when middleware replaces the response.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import type { Context } from 'aws-lambda';

const app = new Router();

// ❌ WRONG: Using destructuring captures a reference to the original response
const _badMiddleware: Middleware = async ({ reqCtx: { res }, next }) => {
  res.headers.set('X-Before', 'Before');
  await next();
  // This header will NOT be added because 'res' is a stale reference
  res.headers.set('X-After', 'After');
};

// ✅ CORRECT: Always access response through reqCtx
const goodMiddleware: Middleware = async ({ reqCtx, next }) => {
  reqCtx.res.headers.set('X-Before', 'Before');
  await next();
  // This header WILL be added because we get the current response
  reqCtx.res.headers.set('X-After', 'After');
};

app.use(goodMiddleware);

app.get('/test', async () => {
  return { message: 'Hello World!' };
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);

During the middleware execution chain, the response object (reqCtx.res) can be replaced by other middleware or the route handler. When you destructure the request context, you capture a reference to the response object as it existed at that moment, not the current response.

Composing middleware

You can create reusable middleware stacks by using the composeMiddleware function to combine multiple middleware into a single middleware function. This is useful for creating standardized middleware combinations that can be shared across different routes or applications.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { composeMiddleware } from '@aws-lambda-powertools/event-handler/http';
import { cors } from '@aws-lambda-powertools/event-handler/http/middleware';
import type { Middleware } from '@aws-lambda-powertools/event-handler/types';
import { Logger } from '@aws-lambda-powertools/logger';

const logger = new Logger();

const logging: Middleware = async ({ reqCtx, next }) => {
  logger.info(`Request: ${reqCtx.req.method} ${reqCtx.req.url}`);
  await next();
  logger.info(`Response: ${reqCtx.res.status}`);
};

const rateLimit: Middleware = async ({ reqCtx, next }) => {
  // Rate limiting logic would go here
  reqCtx.res.headers.set('X-RateLimit-Limit', '100');
  await next();
};

// Reusable composed middleware
const apiMiddleware = composeMiddleware([logging, cors(), rateLimit]);

export { apiMiddleware };
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { Router } from '@aws-lambda-powertools/event-handler/http';
import type { Context } from 'aws-lambda';
import { apiMiddleware } from './advanced_mw_compose_middleware_shared.js';

const app = new Router();

app.use(apiMiddleware);

app.get('/todos', async () => {
  const todos = await getAllTodos();
  return { todos };
});

app.post('/todos', async ({ req }) => {
  const body = await req.json();
  const todo = await putTodo(body);
  return todo;
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);

The composeMiddleware function maintains the same execution order as if you had applied the middleware individually, following the onion pattern where middleware execute in order during pre-processing and in reverse order during post-processing.

Composition order

Unlike traditional function composition which typically works right-to-left, composeMiddleware follows the convention used by most web frameworks and executes middleware left-to-right (first to last in the array). This means composeMiddleware([a, b, c]) executes middleware a first, then b, then c.

Being a good citizen

Middleware can add subtle improvements to request/response processing, but also add significant complexity if you're not careful.

Keep the following in mind when authoring middleware for Event Handler:

  • Call the next middleware. If you are not returning early by returning a Response object or JSON object, always ensure you call the next function.
  • Keep a lean scope. Focus on a single task per middleware to ease composability and maintenance.
  • Catch your own errors. Catch and handle known errors to your logic, unless you want to raise HTTP Errors, or propagate specific errors to the client.
  • Avoid destructuring the response object. As mentioned in the destructuring pitfalls section, always access the response through reqCtx.res rather than destructuring to avoid stale references.

Returning Response objects

You can use the Web API's Response object to have full control over the response. For example, you might want to add additional headers, cookies, or set a custom content type.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { Router } from '@aws-lambda-powertools/event-handler/http';
import { Logger } from '@aws-lambda-powertools/logger';
import type { Context } from 'aws-lambda';

const logger = new Logger();
const app = new Router({ logger });

app.get('/todos', async () => {
  const todos = await getAllTodos();

  return new Response(JSON.stringify({ todos }), {
    status: 200,
    headers: {
      'Content-Type': 'application/json',
      'Cache-Control': 'max-age=300',
      'X-Custom-Header': 'custom-value',
    },
  });
});

app.post('/todos', async ({ req }) => {
  const body = await req.json();
  const todo = await createTodo(body.title);

  return new Response(JSON.stringify(todo), {
    status: 201,
    headers: {
      Location: `/todos/${todo.id}`,
      'Content-Type': 'application/json',
    },
  });
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);
1
2
3
4
5
6
7
8
9
{
  "statusCode": 201,
  "body": "{\"id\":\"123\",\"title\":\"Learn TypeScript\"}",
  "headers": {
    "Content-Type": "application/json",
    "Location": "/todos/123"
  },
  "isBase64Encoded": false
}

CORS

You can configure CORS (Cross-Origin Resource Sharing) by using the cors middleware.

This will ensure that CORS headers are returned as part of the response when your functions match the path invoked and the Origin matches one of the allowed values.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { Router } from '@aws-lambda-powertools/event-handler/http';
import { cors } from '@aws-lambda-powertools/event-handler/http/middleware';
import type { Context } from 'aws-lambda';

const app = new Router();

app.use(
  cors({
    origin: 'https://example.com',
    maxAge: 300,
  })
);

app.get('/todos/:todoId', async ({ params: { todoId } }) => {
  const todo = await getTodoById(todoId);
  return { todo };
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
  "statusCode": 200,
  "headers": {
    "access-control-allow-credentials": "false",
    "access-control-allow-origin": "https://example.com",
    "content-type": "application/json"
  },
  "multiValueHeaders": {
    "access-control-allow-headers": [
      "Authorization",
      "Content-Type",
      "X-Amz-Date",
      "X-Api-Key",
      "X-Amz-Security-Token"
    ],
    "access-control-allow-methods": [
      "DELETE",
      "GET",
      "HEAD",
      "PATCH",
      "POST",
      "PUT"
    ]
  },
  "body": "{\"todoId\":\"123\",\"task\":\"Example task\",\"completed\":false}",
  "isBase64Encoded": false
}

Pre-flight

Pre-flight (OPTIONS) requests are typically handled at the API Gateway or Lambda Function URL as per our sample infrastructure, no Lambda integration is necessary. However, ALB expects you to handle pre-flight requests in your function.

For convenience, when you register the cors middleware, we automatically handle these requests for you as long as the path matches and the Origin header is present and valid.

Defaults

For convenience, these are the default CORS settings applied when you register the cors middleware without any options:

Security consideration

Always set the origin option to a specific domain or list of domains in production environments to avoid security risks associated with allowing all origins.

Key Default Value Description
origin * Specifies the allowed origin(s) that can access the resource. Use * to allow all origins.
methods ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'] Specifies the allowed HTTP methods.
allowHeaders [Authorization, Content-Type, X-Amz-Date, X-Api-Key, X-Amz-Security-Token] Specifies the allowed headers that can be used in the actual request.
exposeHeaders [] Any additional header beyond the safe listed by CORS specification.
credentials false Only necessary when you need to expose cookies, authorization headers or TLS client certificates.

Per-route overrides

You can override the global CORS settings on a per-route basis by passing options to the cors middleware when applying it to a specific route.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Router } from '@aws-lambda-powertools/event-handler/http';
import { cors } from '@aws-lambda-powertools/event-handler/http/middleware';
import type { Context } from 'aws-lambda';

const app = new Router();

app.use(
  cors({
    origin: 'https://example.com',
    maxAge: 300,
  })
);

app.get('/todos/:todoId', async ({ params: { todoId } }) => {
  const todo = await getTodoById(todoId);
  return { todo };
});

app.get('/health', [cors({ origin: '*' })], async () => {
  return { status: 'ok' };
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);
1
2
3
4
5
6
7
8
9
{
  "statusCode": 200,
  "headers": {
    "access-control-allow-origin": "*",
    "content-type": "application/json"
  },
  "body": "{\"status\":\"ok\"}",
  "isBase64Encoded": false
}

Compress

You can enable response compression by using the compress middleware. This will automatically compress responses using gzip and base64 encode them when the client indicates support via the Accept-Encoding header.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import { Router } from '@aws-lambda-powertools/event-handler/http';
import { compress } from '@aws-lambda-powertools/event-handler/http/middleware';
import type { Context } from 'aws-lambda';

const app = new Router();

app.use(compress());

app.get('/todos/:todoId', async ({ params: { todoId } }) => {
  const todo = await getTodoById(todoId);
  return { todo };
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);
1
2
3
4
5
6
7
8
{
  "headers": {
    "Accept-Encoding": "gzip"
  },
  "resource": "/todos/1",
  "path": "/todos/1",
  "httpMethod": "GET"
}
1
2
3
4
5
6
7
8
9
{
  "statusCode": 200,
  "multiValueHeaders": {
    "Content-Type": ["application/json"],
    "Content-Encoding": ["gzip"]
  },
  "body": "H4sIAAAAAAACE42STU4DMQyFrxJl3QXln96AMyAW7sSDLCVxiJ0Kqerd8TCCUOgii1EmP/783pOPXjmw+N3L0TfB+hz8brvxtC5KGtHvfMCIkzZx0HT5MPmNnziViIr2dIYoeNr8Q1x3xHsjcVadIbkZJoq2RXU8zzQROLseQ9505NzeCNQdMJNBE+UmY4zbzjAJhWtlZ57sB84BWtul+rteH2HPlVgWARwjqXkxpklK5gmEHAQqJBMtFsGVygcKmNVRjG0wxvuzGF2L0dpVUOKMC3bfJNjJgWMrCuZk7cUp02AiD72D6WKHHwUDKbiJs6AZ0VZXKOUx4uNvzdxT+E4mLcMA+6G8nzrLQkaxkNEVrFKW2VGbJCoCY7q2V3+tiv5kGThyxfTecDWbgGz/NfYXhL6ePgF9PnFdPgMAAA==",
  "isBase64Encoded": true
}

Binary responses

If you need to return binary data, there are several ways you can do so based on how much control you require.

Auto serialization

As described in the response auto serialization section, when you return a JavaScript object from your route handler, we automatically serialize it to JSON and set the Content-Type header to application/json.

A similar pattern applies to binary data where you can return an ArrayBuffer, a Nodejs stream, or a Web stream directly from your handler. We will automatically serialize the response by setting the isBase64Encoded flag to true and base64 encoding the binary data.

Content types

The default header will be set to application/json. If you wish to change this, e.g., in the case of images, PDFs, videos, etc, then you should use the reqCtx.res.headers object to set the appropriate header.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { createReadStream } from 'node:fs';
import { Router } from '@aws-lambda-powertools/event-handler/http';
import type { Context } from 'aws-lambda';

const app = new Router();

app.get('/logo', async (reqCtx) => {
  reqCtx.res.headers.set('Content-Type', 'image/png');
  return createReadStream(`${process.env.LAMBDA_TASK_ROOT}/logo.png`);
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);
1
2
3
4
5
{
  "resource": "/logo",
  "path": "/logo",
  "httpMethod": "GET"
}
1
2
3
4
5
6
7
8
{
  "body": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjU2cHgiIGhlaWdodD0iMjU2cHgiIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaWRZTWlkIj4KICAgIDx0aXRsZT5BV1MgTGFtYmRhPC90aXRsZT4KICAgIDxkZWZzPgogICAgICAgIDxsaW5lYXJHcmFkaWVudCB4MT0iMCUiIHkxPSIxMDAlIiB4Mj0iMTAwJSIgeTI9IjAlIiBpZD0ibGluZWFyR3JhZGllbnQtMSI+CiAgICAgICAgICAgIDxzdG9wIHN0b3AtY29sb3I9IiNDODUxMUIiIG9mZnNldD0iMCUiPjwvc3RvcD4KICAgICAgICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iI0ZGOTkwMCIgb2Zmc2V0PSIxMDAlIj48L3N0b3A+CiAgICAgICAgPC9saW5lYXJHcmFkaWVudD4KICAgIDwvZGVmcz4KICAgIDxnPgogICAgICAgIDxyZWN0IGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQtMSkiIHg9IjAiIHk9IjAiIHdpZHRoPSIyNTYiIGhlaWdodD0iMjU2Ij48L3JlY3Q+CiAgICAgICAgPHBhdGggZD0iTTg5LjYyNDExMjYsMjExLjIgTDQ5Ljg5MDMyNzcsMjExLjIgTDkzLjgzNTQ4MzIsMTE5LjM0NzIgTDExMy43NDcyOCwxNjAuMzM5MiBMODkuNjI0MTEyNiwyMTEuMiBaIE05Ni43MDI5MzU3LDExMC41Njk2IEM5Ni4xNjQwODU4LDEwOS40NjU2IDk1LjA0MTQ4MTMsMTA4Ljc2NDggOTMuODE2MjM4NCwxMDguNzY0OCBMOTMuODA2NjE2MywxMDguNzY0OCBDOTIuNTcxNzUxNCwxMDguNzY4IDkxLjQ0OTE0NjYsMTA5LjQ3NTIgOTAuOTE5OTE4NywxMTAuNTg1NiBMNDEuOTEzNDIwOCwyMTMuMDIwOCBDNDEuNDM4NzE5NywyMTQuMDEyOCA0MS41MDYwNzU4LDIxNS4xNzc2IDQyLjA5NjI0NTEsMjE2LjEwODggQzQyLjY3OTk5OTQsMjE3LjAzNjggNDMuNzA2MzgwNSwyMTcuNiA0NC44MDY1MzMxLDIxNy42IEw5MS42NTQ0MjMsMjE3LjYgQzkyLjg5NTcwMjcsMjE3LjYgOTQuMDIxNTE0OSwyMTYuODg2NCA5NC41NTM5NTAxLDIxNS43Njk2IEwxMjAuMjAzODU5LDE2MS42ODk2IEMxMjAuNjE3NjE5LDE2MC44MTI4IDEyMC42MTQ0MTIsMTU5Ljc5ODQgMTIwLjE4NzgyMiwxNTguOTI4IEw5Ni43MDI5MzU3LDExMC41Njk2IFogTTIwNy45ODUxMTcsMjExLjIgTDE2OC41MDc5MjgsMjExLjIgTDEwNS4xNzM3ODksNzguNjI0IEMxMDQuNjQ0NTYxLDc3LjUxMDQgMTAzLjUxNTU0MSw3Ni44IDEwMi4yNzc0NjksNzYuOCBMNzYuNDQ3OTQzLDc2LjggTDc2LjQ3NjgwOTksNDQuOCBMMTI3LjEwMzA2Niw0NC44IEwxOTAuMTQ1MzI4LDE3Ny4zNzI4IEMxOTAuNjc0NTU2LDE3OC40ODY0IDE5MS44MDM1NzUsMTc5LjIgMTkzLjA0MTY0NywxNzkuMiBMMjA3Ljk4NTExNywxNzkuMiBMMjA3Ljk4NTExNywyMTEuMiBaIE0yMTEuMTkyNTU4LDE3Mi44IEwxOTUuMDcxOTU4LDE3Mi44IEwxMzIuMDI5Njk2LDQwLjIyNzIgQzEzMS41MDA0NjgsMzkuMTEzNiAxMzAuMzcxNDQ5LDM4LjQgMTI5LjEzMDE2OSwzOC40IEw3My4yNzI1NzYsMzguNCBDNzEuNTA1Mjc1OCwzOC40IDcwLjA2ODM0MjEsMzkuODMwNCA3MC4wNjUxMzQ0LDQxLjU5NjggTDcwLjAyOTg1MjgsNzkuOTk2OCBDNzAuMDI5ODUyOCw4MC44NDggNzAuMzYzNDI2Niw4MS42NjA4IDcwLjk2OTYzMyw4Mi4yNjI0IEM3MS41Njk0MjQ2LDgyLjg2NCA3Mi4zODQxMTQ2LDgzLjIgNzMuMjM3Mjk0MSw4My4yIEwxMDAuMjUzNTczLDgzLjIgTDE2My41OTA5MiwyMTUuNzc2IEMxNjQuMTIzMzU1LDIxNi44ODk2IDE2NS4yNDU5NiwyMTcuNiAxNjYuNDg0MDMyLDIxNy42IEwyMTEuMTkyNTU4LDIxNy42IEMyMTIuOTY2Mjc0LDIxNy42IDIxNC40LDIxNi4xNjY0IDIxNC40LDIxNC40IEwyMTQuNCwxNzYgQzIxNC40LDE3NC4yMzM2IDIxMi45NjYyNzQsMTcyLjggMjExLjE5MjU1OCwxNzIuOCBMMjExLjE5MjU1OCwxNzIuOCBaIiBmaWxsPSIjRkZGRkZGIj48L3BhdGg+CiAgICA8L2c+Cjwvc3ZnPg==",
  "headers": {
    "Content-Type": "image/png"
  },
  "isBase64Encoded": true,
  "statusCode": 200
}

Set isBase64Encoded parameter

You can indicate that you wish to base64 encode any response, regardless of type, by setting the isBase64Encoded field in reqCtx to true.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { Router } from '@aws-lambda-powertools/event-handler/http';
import type { Context } from 'aws-lambda';

const app = new Router();

app.get('/json64', async (reqCtx) => {
  reqCtx.isBase64Encoded = true;
  return { message: 'Hello World!' };
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);
1
2
3
4
5
{
  "resource": "/json64",
  "path": "/json64",
  "httpMethod": "GET"
}
1
2
3
4
5
6
7
8
{
  "body": "eyJtZXNzYWdlIjoiSGVsbG8gV29ybGQifQ==",
  "headers": {
    "Content-Type": "application/json"
  },
  "isBase64Encoded": true,
  "statusCode": 200
}

Manual serialization

For complete control you can return an APIGatewayProxyEvent (v1 or v2) and this will be handled transparently by the resolver.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { readFile } from 'node:fs/promises';
import { Router } from '@aws-lambda-powertools/event-handler/http';
import type { Context } from 'aws-lambda';

const app = new Router();

app.get('/logo', async () => {
  const logoFile = await readFile(`${process.env.LAMBDA_TASK_ROOT}/logo.png`);
  return {
    body: logoFile.toString('base64'),
    isBase64Encoded: true,
    headers: {
      'Content-Type': 'image/png',
    },
    statusCode: 200,
  };
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);
1
2
3
4
5
{
  "resource": "/logo",
  "path": "/logo",
  "httpMethod": "GET"
}
1
2
3
4
5
6
7
8
{
  "body": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjU2cHgiIGhlaWdodD0iMjU2cHgiIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaWRZTWlkIj4KICAgIDx0aXRsZT5BV1MgTGFtYmRhPC90aXRsZT4KICAgIDxkZWZzPgogICAgICAgIDxsaW5lYXJHcmFkaWVudCB4MT0iMCUiIHkxPSIxMDAlIiB4Mj0iMTAwJSIgeTI9IjAlIiBpZD0ibGluZWFyR3JhZGllbnQtMSI+CiAgICAgICAgICAgIDxzdG9wIHN0b3AtY29sb3I9IiNDODUxMUIiIG9mZnNldD0iMCUiPjwvc3RvcD4KICAgICAgICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iI0ZGOTkwMCIgb2Zmc2V0PSIxMDAlIj48L3N0b3A+CiAgICAgICAgPC9saW5lYXJHcmFkaWVudD4KICAgIDwvZGVmcz4KICAgIDxnPgogICAgICAgIDxyZWN0IGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQtMSkiIHg9IjAiIHk9IjAiIHdpZHRoPSIyNTYiIGhlaWdodD0iMjU2Ij48L3JlY3Q+CiAgICAgICAgPHBhdGggZD0iTTg5LjYyNDExMjYsMjExLjIgTDQ5Ljg5MDMyNzcsMjExLjIgTDkzLjgzNTQ4MzIsMTE5LjM0NzIgTDExMy43NDcyOCwxNjAuMzM5MiBMODkuNjI0MTEyNiwyMTEuMiBaIE05Ni43MDI5MzU3LDExMC41Njk2IEM5Ni4xNjQwODU4LDEwOS40NjU2IDk1LjA0MTQ4MTMsMTA4Ljc2NDggOTMuODE2MjM4NCwxMDguNzY0OCBMOTMuODA2NjE2MywxMDguNzY0OCBDOTIuNTcxNzUxNCwxMDguNzY4IDkxLjQ0OTE0NjYsMTA5LjQ3NTIgOTAuOTE5OTE4NywxMTAuNTg1NiBMNDEuOTEzNDIwOCwyMTMuMDIwOCBDNDEuNDM4NzE5NywyMTQuMDEyOCA0MS41MDYwNzU4LDIxNS4xNzc2IDQyLjA5NjI0NTEsMjE2LjEwODggQzQyLjY3OTk5OTQsMjE3LjAzNjggNDMuNzA2MzgwNSwyMTcuNiA0NC44MDY1MzMxLDIxNy42IEw5MS42NTQ0MjMsMjE3LjYgQzkyLjg5NTcwMjcsMjE3LjYgOTQuMDIxNTE0OSwyMTYuODg2NCA5NC41NTM5NTAxLDIxNS43Njk2IEwxMjAuMjAzODU5LDE2MS42ODk2IEMxMjAuNjE3NjE5LDE2MC44MTI4IDEyMC42MTQ0MTIsMTU5Ljc5ODQgMTIwLjE4NzgyMiwxNTguOTI4IEw5Ni43MDI5MzU3LDExMC41Njk2IFogTTIwNy45ODUxMTcsMjExLjIgTDE2OC41MDc5MjgsMjExLjIgTDEwNS4xNzM3ODksNzguNjI0IEMxMDQuNjQ0NTYxLDc3LjUxMDQgMTAzLjUxNTU0MSw3Ni44IDEwMi4yNzc0NjksNzYuOCBMNzYuNDQ3OTQzLDc2LjggTDc2LjQ3NjgwOTksNDQuOCBMMTI3LjEwMzA2Niw0NC44IEwxOTAuMTQ1MzI4LDE3Ny4zNzI4IEMxOTAuNjc0NTU2LDE3OC40ODY0IDE5MS44MDM1NzUsMTc5LjIgMTkzLjA0MTY0NywxNzkuMiBMMjA3Ljk4NTExNywxNzkuMiBMMjA3Ljk4NTExNywyMTEuMiBaIE0yMTEuMTkyNTU4LDE3Mi44IEwxOTUuMDcxOTU4LDE3Mi44IEwxMzIuMDI5Njk2LDQwLjIyNzIgQzEzMS41MDA0NjgsMzkuMTEzNiAxMzAuMzcxNDQ5LDM4LjQgMTI5LjEzMDE2OSwzOC40IEw3My4yNzI1NzYsMzguNCBDNzEuNTA1Mjc1OCwzOC40IDcwLjA2ODM0MjEsMzkuODMwNCA3MC4wNjUxMzQ0LDQxLjU5NjggTDcwLjAyOTg1MjgsNzkuOTk2OCBDNzAuMDI5ODUyOCw4MC44NDggNzAuMzYzNDI2Niw4MS42NjA4IDcwLjk2OTYzMyw4Mi4yNjI0IEM3MS41Njk0MjQ2LDgyLjg2NCA3Mi4zODQxMTQ2LDgzLjIgNzMuMjM3Mjk0MSw4My4yIEwxMDAuMjUzNTczLDgzLjIgTDE2My41OTA5MiwyMTUuNzc2IEMxNjQuMTIzMzU1LDIxNi44ODk2IDE2NS4yNDU5NiwyMTcuNiAxNjYuNDg0MDMyLDIxNy42IEwyMTEuMTkyNTU4LDIxNy42IEMyMTIuOTY2Mjc0LDIxNy42IDIxNC40LDIxNi4xNjY0IDIxNC40LDIxNC40IEwyMTQuNCwxNzYgQzIxNC40LDE3NC4yMzM2IDIxMi45NjYyNzQsMTcyLjggMjExLjE5MjU1OCwxNzIuOCBMMjExLjE5MjU1OCwxNzIuOCBaIiBmaWxsPSIjRkZGRkZGIj48L3BhdGg+CiAgICA8L2c+Cjwvc3ZnPg==",
  "headers": {
    "Content-Type": "image/png"
  },
  "isBase64Encoded": true,
  "statusCode": 200
}

Compression

If you wish to use binary responses together with the compress feature, the client must send the Accept header with the correct media type.

Response streaming

Compatibility

Response streaming is only available for API Gateway REST APIs and Lambda function URLs.

You can send responses to the client using HTTP streaming by wrapping your router with the streamify function to turn all the associated route handlers into stream compatible handlers. This is useful when you need to send large payloads or want to start sending data before the entire response is ready.

In order to gain the most benefit, you should return either a readable Nodejs stream, a duplex Nodejs stream, or a Web stream from your handlers. However, you can also return other types and these will also be delivered via HTTP streaming.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import { Router, streamify } from '@aws-lambda-powertools/event-handler/http';

const app = new Router();

app.get('/video-stream', async (reqCtx) => {
  reqCtx.res.headers.set('content-type', 'video/mp4');
  return createVideoStream();
});

app.get('/hello', () => {
  return { message: 'Hello World' };
});

export const handler = streamify(app);

When to use streaming

Consider response streaming when:

  • Returning large payloads (> 6MB)
  • Processing data that can be sent incrementally
  • Reducing time-to-first-byte for long-running operations is a requirement

For most use cases, the standard resolve method is sufficient.

Debug mode

You can enable debug mode via the POWERTOOLS_DEV environment variable.

When set to true, debug mode enhances error responses with detailed information to aid in debugging and testing.

Security consideration

Never enable debug mode in production environments as it exposes sensitive error details that could be exploited by attackers.

Only use it during development and testing.

Enhanced error responses

When an unhandled error occurs in your route handler or middleware, Event Handler will return a HTTP 500 response by default.

1
2
3
4
5
{
  "statusCode": 500,
  "error": "Internal Server Error",
  "message": "Internal Server Error"
}
1
2
3
4
5
6
7
8
9
{
  "statusCode": 500,
  "error": "Internal Server Error",
  "message": "Actual error message from the exception",
  "stack": "Full stack trace of the error",
  "details": {
    "errorName": "Name of the error class"
  }
}

Logging requests and responses

Coming soon

Please check this issue and add 👍 if you would like us to prioritize this feature.

OpenAPI

Coming soon

Currently, Event Handler does not support automatic generation of OpenAPI documentation from your route definitions.

We plan to add this feature in a future release with an experience similar to what described in the utility's RFC and to what available in Powertools for AWS Lambda (Python).

Please check this issue for more details, and add 👍 if you would like us to prioritize it.

Split routers

As applications grow and the number of routes a Lambda function handles increases, it becomes natural to either break it into smaller Lambda functions or split routes into separate files to ease maintenance.

The Router class provide an includeRouter method to compose multiple router instances allowing developers to define routes in multiple files and merge route definitions. You will be able to define routes in separate files and import them into a main router file, improving code organization and maintainability.

Merging with Global Middleware

When merging two Router instances together, if you have a global middleware defined in one of your instances, the global middleware gets applied to the all the merged routes.

Let's assume you have index.ts as your Lambda function entrypoint and routes in split_route.ts. This is how you'd use the includeRouter feature.

1
2
3
4
5
6
7
import { Router } from '@aws-lambda-powertools/event-handler/http';

const router = new Router();
router.get('/todos', () => 'Get all todos');
router.get('/todos/:id', () => 'Get a single todo item');

export { router };
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import { Router } from '@aws-lambda-powertools/event-handler/http';
import type { APIGatewayProxyEvent, Context } from 'aws-lambda';
import { router } from './split_route';

const app = new Router();

// Split Routers
app.includeRouter(router);

export const handler = async (event: APIGatewayProxyEvent, context: Context) =>
  app.resolve(event, context);

Route Prefix

In the previous example, split_route.ts routes had a /todos prefix. This might grow over time and become repetitive.

When necessary, you can set a prefix when including a Router instance. This means you can remove /todos prefix altogether.

1
2
3
4
5
6
7
import { Router } from '@aws-lambda-powertools/event-handler/http';

const router = new Router();
router.get('/', () => 'Get all todos');
router.get('/:id', () => 'Get a single todo item');

export { router };
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import { Router } from '@aws-lambda-powertools/event-handler/http';
import type { APIGatewayProxyEvent, Context } from 'aws-lambda';
import { router } from './split_route';

const app = new Router();

// Split Routers
app.includeRouter(router, { prefix: '/todos' });

export const handler = async (event: APIGatewayProxyEvent, context: Context) =>
  app.resolve(event, context);

Considerations

This utility is optimized for AWS Lambda computing model and prioritizes fast startup, minimal feature set, and quick onboarding for triggers supported by Lambda.

Event Handler naturally leads to a single Lambda function handling multiple routes for a given service, which can be eventually broken into multiple functions.

Both single (monolithic) and multiple functions (micro) offer different set of trade-offs worth knowing.

TL;DR;

Start with a monolithic function, add additional functions with new handlers, and possibly break into micro functions if necessary.

Monolithic function

monolithic function

A monolithic function means that your final code artifact will be deployed to a single function. This is generally the best approach to start.

Benefits

  • Code reuse. It's easier to reason about your service, modularize it and reuse code as it grows. Eventually, it can be turned into a standalone library.
  • No custom tooling. Monolithic functions are treated just like normal Typescript packages; no upfront investment in tooling.
  • Faster deployment and debugging. Whether you use all-at-once, linear, or canary deployments, a monolithic function is a single deployable unit. IDEs like WebStorm and VSCode have tooling to quickly profile, visualize, and step through debug any Typescript package.

Downsides

  • Cold starts. Frequent deployments and/or high load can diminish the benefit of monolithic functions depending on your latency requirements, due to the Lambda scaling model. Always load test to find a pragmatic balance between customer experience and developer cognitive load.
  • Granular security permissions. The micro function approach enables you to use fine-grained permissions and access controls, separate external dependencies and code signing at the function level. Conversely, you could have multiple functions while duplicating the final code artifact in a monolithic approach. Regardless, least privilege can be applied to either approaches.
  • Higher risk per deployment. A misconfiguration or invalid import can cause disruption if not caught early in automated testing. Multiple functions can mitigate misconfigurations but they will still share the same code artifact. You can further minimize risks with multiple environments in your CI/CD pipeline.

Micro function

micro function

A micro function means that your final code artifact will be different to each function deployed. This is generally the approach to start if you're looking for fine-grain control and/or high load on certain parts of your service.

Benefits

  • Granular scaling. A micro function can benefit from the Lambda scaling model to scale differently depending on each part of your application. Concurrency controls and provisioned concurrency can also be used at a granular level for capacity management.
  • Discoverability. Micro functions are easier to visualize when using distributed tracing. Their high-level architectures can be self-explanatory, and complexity is highly visible — assuming each function is named after the business purpose it serves.
  • Package size. An independent function can be significantly smaller (KB vs MB) depending on the external dependencies it requires to perform its purpose. Conversely, a monolithic approach can benefit from Lambda Layers to optimize builds for external dependencies.

Downsides

  • Upfront investment. You need custom build tooling to bundle assets, including native bindings for runtime compatibility. Operations become more elaborate — you need to standardize tracing labels/annotations, structured logging, and metrics to pinpoint root causes.
  • Engineering discipline is necessary for both approaches. However, the micro-function approach requires further attention to consistency as the number of functions grow, just like any distributed system.
  • Harder to share code. Shared code must be carefully evaluated to avoid unnecessary deployments when this code changes. Equally, if shared code isn't a library, your development, building, deployment tooling need to accommodate the distinct layout.
  • Slower safe deployments. Safely deploying multiple functions require coordination — AWS CodeDeploy deploys and verifies each function sequentially. This increases lead time substantially (minutes to hours) depending on the deployment strategy you choose. You can mitigate it by selectively enabling it in prod-like environments only, and where the risk profile is applicable. Automated testing, operational and security reviews are essential to stability in either approaches.

Testing your code

You can use any testing framework of your choice to test Lambda functions using Event Handler.

Since Event Handler doesn't require any server or socket to run, you can test your code as you would any other JavaScript/TypeScript function.

Below is an example using Vitest, including a helper function to create mock API Gateway events that you can copy and adapt to your needs.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import type { Context } from 'aws-lambda';
import { expect, test } from 'vitest';
import { handler } from './advanced_cors_simple.js';
import { createTestEvent } from './advanced_testing_helper.js';

test('returns CORS headers', async () => {
  // Preapare
  const event = createTestEvent({
    httpMethod: 'GET',
    headers: {
      Origin: 'https://example.com',
    },
    path: '/todos/123',
  });

  // Act
  const result = await handler(event, {} as Context);

  // Assess
  expect(result.statusCode).toEqual(200);
  expect(result.body).toEqual(JSON.stringify({ todo: { id: '123' } }));
  expect(result.headers?.['access-control-allow-origin']).toEqual(
    'https://example.com'
  );
  expect(
    result.multiValueHeaders?.['access-control-allow-methods'].sort()
  ).toEqual(['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'].sort());
  expect(
    result.multiValueHeaders?.['access-control-allow-headers'].sort()
  ).toEqual(
    [
      'Authorization',
      'Content-Type',
      'X-Amz-Date',
      'X-Amz-Security-Token',
      'X-Api-Key',
    ].sort()
  );
});
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import type { APIGatewayProxyEvent } from 'aws-lambda';

const createTestEvent = (options: {
  path: string;
  httpMethod: string;
  headers?: Record<string, string>;
}): APIGatewayProxyEvent => ({
  path: options.path,
  httpMethod: options.httpMethod,
  headers: options.headers ?? {},
  body: null,
  multiValueHeaders: {},
  isBase64Encoded: false,
  pathParameters: null,
  queryStringParameters: null,
  multiValueQueryStringParameters: null,
  stageVariables: null,
  requestContext: {
    httpMethod: options.httpMethod,
    path: options.path,
    domainName: 'localhost',
  } as APIGatewayProxyEvent['requestContext'],
  resource: '',
});

export { createTestEvent };
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { Router } from '@aws-lambda-powertools/event-handler/http';
import { cors } from '@aws-lambda-powertools/event-handler/http/middleware';
import type { Context } from 'aws-lambda';

const app = new Router();

app.use(
  cors({
    origin: 'https://example.com',
    maxAge: 300,
  })
);

app.get('/todos/:todoId', async ({ params: { todoId } }) => {
  const todo = await getTodoById(todoId);
  return { todo };
});

export const handler = async (event: unknown, context: Context) =>
  app.resolve(event, context);