Develop APIs Quickly and Operate Them at Low Cost Using Lambda, TypeScript, and Express.js

Introduction
Hello!
My name is Kameyama and I work as a web engineer at KINTO Technologies.
I currently work in the Marketing Product Group.
I this article I will talk about how I built a serverless architecture.
With container-based applications like those running on ECS, you're charged for CPU and memory usage based on uptime, even when there are no incoming requests. This means you can end up paying for resources you’re not actually using, especially in PoC development or in products with very low traffic.
For these types of use cases, it is possible to significantly reduce running costs by using a pay-as-you-go serverless architecture, in which the server runs only when in use and automatically stops if no processing is performed for a certain period of time.
To achieve this, we built a Lambda-based application with the following key points:
- Serverless development using AWS API Gateway + Lambda
- Simple and versatile API design with TypeScript + Express
About Serverless
We decided to adopt Lambda, which is widely used as part of AWS's serverless configuration. As mentioned earlier, Lambda automatically handles server startup, shutdown, and scaling, and its pay-as-you-go pricing means you are charged only for what you use, minimizing costs.
On the other hand, a disadvantage of such serverless APIs is that response delays due to cold starts occur. This is especially true in environments with a small number of requests or when there has been no access for a certain period of time, Lambda goes into sleep mode, and when a request comes again, it takes time for the container to start up (actual measured response time is about 1 second).
In summary, this infrastructure configuration is especially recommended for those who want to quickly build a prototype or develop a tool for users who can tolerate response delays (such as internal members).
How Much Cheaper with Lambda?
Let's compare the costs of Fargate, an always-running container, and Lambda, the serverless type we will use this time.
Fargate
Assuming 0.5 vCPU and 2GB of memory, the estimated operating cost per task per hour is as follows:
- vCPU cost: 0.5 vCPU x $0.04048 per vCPU-hour = $0.02024/hour
- Memory cost: 2GB x $0.004445 per GB-hour = $0.00889/hour
Based on these calculations, the total cost per hour is $0.02024 + $0.00889 = $0.02913. If the task runs continuously for a full month (720 hours), the monthly cost per task would be $20.9736. (However, you can save the cost by shutting down at night or lowering the vCPU specs.)
This is the cost per environment, so if you need multiple environments, such as a production and development, the total cost will scale accordingly.
Lambda
AWS Lambda cost On the other hand, Lambda costs are calculated based on the number of requests and the compute time of the container temporarily activated in response to those requests.
- 0.00001667 USD per GB-second
- $0.20 per 1,000,000 requests
Assuming 2GB like Fargate, a compute time of 0.5 seconds per request, and 100,000 requests per month, the total monthly cost for Lambda is $0.02 (request cost) + $1.6667 (compute cost) = approximately $1.69 per month.
Even better, even if you increase the number of environments or the number of Lambdas per environment, the total cost remains the same as long as the total number of requests is unchanged.
These cost simulations demonstrate the cost advantages of Lambda.
This kind of cost reduction is especially beneficial for low-traffic internal tools that don't generate revenue, or for PoC products, as it helps lower financial barriers.
About Express
We adopted Express as the server-side JavaScript framework.
Express is designed to allow the intuitive understanding of the concepts of routing and middleware. Its configuration is easy to handle even for developers doing server-side development with Node.js for the first time. Express allows smooth scaling from small APIs to medium and large applications. The routing description is also concise.
app.get('/users/:id', (req, res) => {
res.send(`User: ${req.params.id}`);
});
You can easily incorporate a wide range of middleware libraries depending on your needs, such as morgan for log output, passport for authentication, express-validator for input validation, etc. This makes it easier to add features to and maintain your application.
It is possible to build an endpoint using the Lambda library officially distributed by AWS, but if you build it using the general-purpose library Express, it will be easier to reuse the code after the endpoint when switching to ECS or App Runner as the scale of your application expands, rather than using a Lambda-specific library.
Development Policy
In this article, I adopted a configuration in which multiple API endpoints are consolidated into a single Lambda function.
This is to make the most of Lambda's "hot start" feature.
Once a Lambda function is started, it remains in memory for a certain period of time, which is called a "hot start" state. Therefore, after one API is requested and Lambda is launched, requests to other APIs within the same function can also be processed speedily.
By taking advantage of this property, you can expect improved performance during operation.
However, Lambda has a limit on the deployable package size (50MB or less when zipped and 250MB or less after unzipped), so if you pack all the APIs in your application into a single function, you will eventually reach this limit, making it unrealistic.
For this reason, I will assume a structure in which related APIs are grouped into the same Lambda function by screen or functional unit. Ultimately, I will proceed on the assumption of a monorepo structure in which multiple Lambda functions are managed within a single repository.
In this article, the goal is to enable local execution using SAM, and I will omit the configuration of the AWS console or what happens after deployment.
Environment Building (Preparation Before Coding)
In this article, I will explain how to build an environment that combines pnpm, which makes it easy to manage multiple Lambda functions and shared code, with AWS SAM.
The entire project is managed as a pnpm workspace, and each Lambda function and common library is treated as an independent workspace. The deployment tool used is AWS SAM (Serverless Application Model).
Mainly, the following tools are required.
- Node.js
- pnpm
- AWS CLI
- AWS SAM CLI
- Git (version control)
Git installation is omitted.
Installing Required Tools
Node.js
Node.js is required as before. You can install the LTS version from the official website.
After installation, check that the version is displayed with the following command.
node -v
npm -v # pnpmをインストールするために使用する
pnpm
Use pnpm to manage dependent libraries. pnpm is particularly good at resolving dependencies and the efficient use of disk space in a monorepo configuration where multiple modules (Lambda functions) are managed in a single repository.
Install pnpm using the following method:
npm install -g pnpm
For methods using curl or others, please refer to the official pnpm website. pnpm installation guide
After installation, check the version with the following command:
pnpm -v
AWS CLI
As before, the AWS CLI is required for linkage with AWS. Install it and set up your credentials using aws configure.
AWS SAM CLI
This time I will use AWS SAM (Serverless Application Model) as the deployment tool. AWS SAM is an infrastructure as code (IaC) framework for serverless applications, and the SAM CLI supports local build, testing, and deployment.
Refer to the official website below and install AWS SAM CLI according to your operating system.
AWS SAM CLI Installation Guide
After installation, check the version with the following command:
sam --version
Project Structure and Workspace Setup
In the root directory of the project, place package.json
, which defines the config files for the entire monorepo and the dependencies of tools commonly used during development (e.g., esbuild). Each Lambda function and common library is created as an independent subdirectory, for example, inside the functions
directory, and these are defined as pnpm workspaces.
Using the provided structure as a reference, I will explain the basic structure and configuration files.
sample-app/ # (ルートディレクトリ)
├── functions/
│ ├── common/ # 共通コード用ワークスペース
│ │ ├── package.json
│ │ ├── src/
│ │ └── tsconfig.json
│ ├── function-1/ # Lambda関数1用ワークスペース
│ │ ├── package.json
│ │ ├── src/ # Expressアプリやハンドラコード
│ │ └── tsconfig.json
│ └── function-2/ # Lambda関数2用ワークスペース
│ ├── package.json
│ ├── src/
│ └── tsconfig.json
├── node_modules/ # pnpmによって管理される依存ライブラリ
├── package.json # ルートのpackage.json
├── pnpm-lock.yaml # ルートのロックファイル
├── pnpm-workspace.yaml # ワークスペース定義ファイル
├── samconfig.toml # SAM デプロイ設定ファイル (初回デプロイで生成)
└── template.yaml # AWS SAM テンプレートファイル
Root package.json
This defines scripts and development tools (such as esbuild) shared across the entire repository.
package.json
{
"name": "sample-lambda-app-root", // プロジェクト全体を表す名前
"version": "1.0.0",
"description": "Serverless Express Monorepo with SAM and pnpm",
"main": "index.js",
"private": true, // ルートパッケージは公開しない設定
"workspaces": [
"functions/*" // ワークスペースとなるディレクトリを指定
],
"scripts": {
"build": "pnpm -r build", // 全ワークスペースの build スクリプトを実行
"sam:build": "sam build", // SAMでのビルド (後述)
"sam:local": "sam local start-api", // SAMでのローカル実行 (後述)
"sam:deploy": "sam deploy" // SAMでのデプロイ (後述)
},
"devDependencies": {
"esbuild": "^0.25.3" // 各ワークスペースのビルドで使う esbuild をルートで管理
// 他、monorepo全体で使う開発ツールがあればここに追加
},
"keywords": [],
"author": "",
"license": "ISC"
}
pnpm-workspace.yaml
This defines which directories should be handled as workspaces.
pnpm-workspace.yaml
packages:
- 'functions/*' # `functions` ディレクトリ内の全てのサブディレクトリをワークスペースとする
# - 'packages/*' # 別のワークスペースグループがあれば追加
Dependency Management (pnpm workspaces)
Describe the dependent libraries required for each Lambda function or common library in the package.json inside each workspace.
Example: functions/function-1/package.json
{
"name": "function-1", // ワークスペースの名前
"version": "1.0.0",
"description": "Lambda Function 1 with Express",
"scripts": {
"build": "esbuild src/app.ts --bundle --minify --sourcemap --platform=node --outfile=dist/app.js", // esbuildでビルド
"start:dev": "nodemon --watch src -e ts --exec \"node dist/app.js\"", // ローカルテスト用のスクリプト (SAM Localとは別に用意しても良い)
"tsc": "tsc" // 型チェック用
},
"dependencies": {
"@codegenie/serverless-express": "^4.16.0", // Lambdaアダプター
"express": "^5.1.0",
"@sample-lambda-app/common": "workspace:*" // 共通ライブラリへの依存
},
"devDependencies": {
"@types/aws-lambda": "^8.10.138", // Lambdaの型定義
"@types/express": "^4.17.21",
"nodemon": "^3.1.0",
"typescript": "^5.4.5"
// esbuild はルートの devDependencies にあるのでここでは不要
},
"keywords": [],
"author": "",
"license": "ISC"
}
-
@sample-lambda-app/common
: This refers to thefunctions/common
workspace. By designating"workspace:*"
, the localcommon
workspace will be referred to. It needs to be defined as"name": "@sample-lambda-app/common"
inpackage.json
on thecommon
workspace side. -
scripts.build
: This is an example of usingesbuild
to bundle TypeScript code and dependent libraries together into a single JavaScript file (dist/app.js). This is an important step to reduce the package size deployed to Lambda.
To install dependent libraries, run pnpm install
only once in the root directory of the project. pnpm looks at pnpm-workspace.yaml
and resolves the dependencies described in package.json
for each workspace, efficiently configuring node_modules
.
pnpm install
To add a library to a specific workspace, run the following command from the root directory:
pnpm add <package-name> -w <workspace-name> # 例: pnpm add axios -w functions/function-1
pnpm add -D <dev-package-name> -w <workspace-name> # 開発依存の場合
Let's Actually Write Some Sample Code
The directory configuration explained earlier includes two function modules, function-1
and function-2
, to create a multi-function configuration, as well as a module called common
so that these functions can use it as a shared component.
Now let’s write some actual code.
Common Code
First, let's write a sample middleware function in common, which is a common component.
functions/common/src/middlewares/hello.ts
import { Request, Response, NextFunction } from 'express';
/**
* サンプル共通ミドルウェア
* リクエストログを出力し、カスタムヘッダーを追加します。
*/
export const helloMiddleware = (req: Request, res: Response, next: NextFunction) => {
console.log(`[Common Middleware] Received request: ${req.method} ${req.path}`);
// レスポンスにカスタムヘッダーを追加
res.setHeader('X-Sample-Common-Middleware', 'Applied');
// 次のミドルウェアまたはルートハンドラに進む
next();
};
続いて、middlewares/内のエクスポートを追加します。
functions/common/src/middlewares/index.ts
export * from './hello';
// middlewares内に他のミドルウェアがあればここに追加していく
さらにワークスペースのトップレベルのsrc/でもエクスポートしてあげる必要があります。
functions/common/src/index.ts
export * from './middlewares';
// middlewaresのような共通処理が他にあればここに追加していく(utilsとか)
Code for function-1
Next, I will write the code for function-1.
functions/function-1/src/app.ts
import express from 'express';
import serverlessExpress from '@codegenie/serverless-express';
import { helloMiddleware, errorHandler } from '@sample-lambda-app/common'; // 共通ミドルウェア、エラーハンドラをインポート
// apiRouter のインポートは不要になりました
// import apiRouter from './routes/api';
// import cookieParser from 'cookie-parser'; // 必要に応じてインストール・インポート
const app = express();
// express標準ミドルウェアの適用
app.use(express.json()); // JSONボディのパースを有効化
// app.use(cookieParser()); // クッキーパースが必要な場合このように追加する
// 共通ミドルウェアの適用
app.use(helloMiddleware);
app.get('/hello', (req, res) => {
console.log('[Function 1 App] Handling GET /hello');
res.json({ message: 'Hello from Function 1 /hello (Simplified)!' });
});
app.post('/users', (req, res) => {
console.log('[Function 1 App] Handling POST /users');
console.log('Request Body:', req.body); // JSONボディをログ出力
res.status(201).json({ received: req.body, status: 'User created (sample)' });
});
// common等にエラーハンドラミドルウェアを作成し、使用する場合は全てのミドルウェアとルート定義の後に配置する。
// app.use(errorHandler); // 本記事では作成していない
// ハンドラのエクスポート
export const handler = serverlessExpress({ app });
Note: In the API Gateway configuration in template.yaml that will be done later, the path without /function1 will be passed, so the route defined here will be a relative path from the API Gateway base path. For example, if a request to API Gateway is /function1/hello, it will match the /hello defined here.
Code for Function-2
functions/function-2/src/app.ts
import express from 'express';
import serverlessExpress from '@codegenie/serverless-express'; // ★アダプターをインポート★
import { helloMiddleware, errorHandler } from '@sample-lambda-app/common'; // 共通ミドルウェア、エラーハンドラをインポート
// ルーターファイルは使用しないためインポート不要
// import apiRouter from './routes/api';
// import cookieParser from 'cookie-parser'; // 必要に応じてインストール・インポート
const app = express();
// express標準ミドルウェアの適用
app.use(express.json()); // JSONボディのパースを有効化
// app.use(cookieParser()); // クッキーパースが必要な場合このように追加する
// 共通ミドルウェアの適用
app.use(helloMiddleware);
// ルートをごとに処理を定義
app.get('/bye', (req, res) => {
console.log('[Function 2 App] Handling GET /bye');
res.json({ message: 'Goodbye from Function 2 /bye!' });
});
app.post('/items', (req, res) => {
console.log('[Function 2 App] Handling POST /items');
console.log('Request Body:', req.body); // JSONボディをログ出力
res.status(201).json({ received: req.body, status: 'Item created (sample)' });
});
app.get('/status', (req, res) => {
console.log('[Function 2 App] Handling GET /status');
res.json({ status: 'OK', function: 'Function 2 is running (Simplified)' });
});
// common等にエラーハンドラミドルウェアを作成し、使用する場合は全てのミドルウェアとルート定義の後に配置する。
// app.use(errorHandler); // 本記事では作成していない
// ハンドラのエクスポート
export const handler = serverlessExpress({ app });
Since this is just a sample, all the processing within the route is written using arrow functions, but in actual development, if the processing becomes complicated it may be better to consolidate the processing into a separate ts file.
Also, during development, there may be times when you want to use different middleware for each route. In such a case, you can create an API router more flexibly by using the express Router library, so please look into it and give it a try. (Reference: https://expressjs.com/en/guide/routing.html https://expressjs.com/ja/guide/routing.html )
Preparing to Locally Run SAM
AWS SAM template (template.yaml)
Create a template.yaml
file in the project route to define the AWS resources to be deployed. Describe Lambda functions, API Gateway, necessary IAM roles, and others.
template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Sample Serverless Application
Globals:
# Functions 全体に適用する共通設定 (メモリサイズやタイムアウトなど)
Function:
Timeout: 30
MemorySize: 256 # 適宜調整する
Runtime: nodejs20.x
Architectures:
- x86_64
Environment:
Variables:
NODE_ENV: production
Resources:
# function-1 ワークスペースに対応するLambda関数リソース定義
Function1:
Type: AWS::Serverless::Function # AWS SAMで定義するサーバーレス関数
Properties:
FunctionName: sample-express-function-1 # AWSコンソールに表示されるLambda関数名 (任意)
Description: Express App for Function 1 (Simplified)
# CodeUri は SAM がコードをパッケージングする際のソースディレクトリを指す。
# ここには、sam build 前のソースコードがあるディレクトリを指定。
CodeUri: functions/function-1/
# Handler は、sam build によって生成された成果物の中でのエントリーポイントを指す。
# esbuild が src/app.ts を dist/handler.js にバンドルし、
# その中で 'export const handler = ...' を CommonJS の 'exports.handler = ...' に変換するため、
# 'ファイル名(拡張子なし).エクスポート名' と記述する。
Handler: handler.handler
Events:
# API Gateway からのトリガー設定
Function1Api:
Type: Api # API Gateway REST APIをトリガーとする
Properties:
Path: /function1/{proxy+}
# 許可するHTTPメソッド (ANYは全てのメソッドを許可)
Method: ANY
# function-2 ワークスペースに対応するLambda関数リソース定義
Function2:
Type: AWS::Serverless::Function
Properties:
FunctionName: sample-express-function-2 # AWSコンソールに表示されるLambda関数名 (任意)
Description: Express App for Function 2 (Simplified)
# CodeUri は function-2 ワークスペースのソースディレクトリを指す
CodeUri: functions/function-2/
# Handler は function-2 のビルド成果物の中でのエントリーポイントを指す
Handler: handler.handler
Events:
# API Gateway からのトリガー設定 (function-2用)
Function2Api:
Type: Api
Properties:
# Function 2 が処理するAPI Gatewayパス
Path: /function2/{proxy+}
Method: ANY
Transform: AWS::Serverless-2016-10-31
: This indicates a SAM template.Resources
: This defines the AWS resources to be deployed.Type:AWS::Serverless::Function
: This is a Lambda function resource.CodeUri
: This specifies the directory where the code to be deployed as a Lambda function is located. This specifies the location of the build artifact for each workspace, such asfunctions/function-1/dist
.Handler
: This specifies the function name in the code that is called first when the Lambda function is executed. This becomes the function name exported in the bundled file (dist/app.js
).Events
: This sets the events that trigger the Lambda function.Type: Api
is a setting that triggers an HTTP request from API Gateway. This setting links to a specific endpoint usingPath
andMethod
./{proxy+}
is a notation that catches all requests under the path.
Local Development and Testing (AWS SAM CLI)
The AWS SAM CLI allows you to emulate and test Lambda functions and API Gateway in your local environment.
-
Build of each workspace: First, build the source code for each workspace into JavaScript. You can use the scripts defined in the root directory.
pnpm run build # functions/* 以下のそれぞれの build スクリプトが実行される
This generates build artifacts such as
functions/function-1/dist/app.js
. -
SAM build: Next, AWS SAM runs a build to create a package for deployment.
sam build
This command reads
template.yaml
, copies the build artifacts from the location specified byCodeUri:
to a location under the.aws-sam/build
directory, and organizes them into the format required by Lambda. -
Local API startup: The Local API feature provided by SAM CLI allows you to emulate API Gateway and run Lambda code locally.
sam local start-api
After the command is executed, a local server will start at a URL such as
http://127.0.0.1:3000
. By accessing the path defined intemplate.yaml
(e.g.,/function1/hello
) via a browser, Postman, or curl, the Lambda function will be executed locally.
After changing the source code during local development, you can either re-run pnpm run build → sam build → sam local start-api or use the sam local start-api --watch option to monitor code changes. (The --watch option automatically restarts the build and emulation, but depending on the actual environment configuration, some adjustments may be required.)
Conclusion
This time, I presented how to locally run a serverless TypeScript using Lambda and Express. To actually release the product, it is necessary to build up AWS infrastructure and make appropriate settings.
Since this was my first attempt with Express and a monorepo configuration, I ran into some difficulties. I have provided detailed explanations as a reminder, so this article may have ended up being a bit long.
I hope this will be of some help to others who are facing similar challenges.
関連記事 | Related Posts

Develop APIs Quickly and Operate Them at Low Cost Using Lambda, TypeScript, and Express.js

AWSサーバレスアーキテクチャをMonorepoツール - Nxとterraformで構築してみた!

Building an AWS Serverless Architecture Using Nx Monorepo Tool and Terraform

SvelteKitをAWSにデプロイ - Svelte不定期連載-06

ECS環境のAuto Provisioningを実現する仕組み

Aurora MySQL におけるロック競合(ブロッキング)の原因を事後調査できる仕組みを作った話
We are hiring!
【フロントエンドエンジニア(コンテンツ開発)】新車サブスク開発G/東京・大阪・福岡
新車サブスク開発グループについてTOYOTAのクルマのサブスクリプションサービスである『 KINTO ONE 』のWebサイトの開発、運用をしています。業務内容トヨタグループの金融、モビリティサービスの内製開発組織である同社にて、自社サービスである、クルマのサブスクリプションサービス『KINTO ONE』のWebサイトコンテンツの開発・運用業務を担っていただきます。
【クラウドエンジニア】Cloud Infrastructure G/東京・大阪・福岡
KINTO Tech BlogWantedlyストーリーCloud InfrastructureグループについてAWSを主としたクラウドインフラの設計、構築、運用を主に担当しています。