Skip to content
Ayhan Sipahi Ayhan Sipahi

Running Bun and Alternative JavaScript Runtimes on AWS Lambda

Run Bun and Deno on AWS Lambda with custom runtimes: performance benchmarks, cost analysis, and production deployment patterns.

AWS Lambda officially supports Node.js, but the platform’s custom runtime capability opens the door to alternative JavaScript runtimes like Bun and Deno. Two mechanisms make that work: Lambda Layers and container images. Both give up the initialization tuning AWS applies to its own managed runtimes, and that trade lands on cold starts.

For most Lambda workloads the managed Node.js runtime stays the right default. An alternative runtime earns its slot only when a measured constraint justifies it, and in that case a container image with a pre-warmed Deno cache is the more predictable of the two paths.

The Custom Runtime Question

Alternative JavaScript runtimes become attractive in a few recurring situations: cold start overhead in latency-sensitive applications, avoiding a TypeScript transpilation step, CPU-bound workloads where runtime efficiency shows up on the bill, and access to modern JavaScript features ahead of Node.js LTS support.

The core trade-off: AWS Lambda is heavily optimized for its managed runtimes, and a custom runtime gives those optimizations up. Any performance gain has to cover both the cold start penalty and the implementation cost.

Understanding Lambda Custom Runtimes

AWS Lambda’s custom runtime feature allows you to run any runtime by implementing the Lambda Runtime API. This API provides a simple HTTP interface that your runtime uses to receive events and return responses.

The Runtime API Flow

// Simplified Lambda Runtime API implementation
const RUNTIME_API = `http://${process.env.AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime`;

while (true) {
  // 1. Get next invocation
  const eventResponse = await fetch(`${RUNTIME_API}/invocation/next`);
  const requestId = eventResponse.headers.get('Lambda-Runtime-Aws-Request-Id');
  const event = await eventResponse.json();

  try {
    // 2. Invoke handler
    const result = await handler(event);

    // 3. Return response
    await fetch(`${RUNTIME_API}/invocation/${requestId}/response`, {
      method: 'POST',
      body: JSON.stringify(result),
    });
  } catch (error) {
    // 4. Report error
    await fetch(`${RUNTIME_API}/invocation/${requestId}/error`, {
      method: 'POST',
      body: JSON.stringify({
        errorMessage: error.message,
        errorType: error.constructor.name,
      }),
    });
  }
}

The bootstrap process runs in an infinite loop, requesting events from Lambda, executing your handler, and returning results. This simple protocol is what makes custom runtimes possible.

Implementation Approach 1: Bun with Lambda Layers

Lambda Layers provide a way to package and share runtime dependencies across multiple functions. Bun maintains an official bun-lambda package that implements the Runtime API.

Building the Bun Lambda Layer

# Clone Bun repository
git clone https://github.com/oven-sh/bun.git
cd bun/packages/bun-lambda

# Build and publish layer (defaults to arm64)
bun run publish-layer

# Build for x86_64 (recommended for compatibility)
ARCH=x64 bun run publish-layer

The publish script creates a Lambda Layer with the Bun runtime and bootstrap script, then publishes it to your AWS account. You’ll get back a Layer ARN that looks like arn:aws:lambda:us-east-1:123456789012:layer:bun-runtime:1.

Writing a Bun Lambda Handler

Bun Lambda handlers follow the Web API standard instead of Node.js conventions:

// handler.ts - Bun Lambda handler
export default {
  async fetch(request: Request): Promise<Response> {
    const event = await request.json();

    // Process Lambda event
    const result = {
      message: 'Hello from Bun on Lambda!',
      timestamp: Date.now(),
      input: event,
    };

    return new Response(JSON.stringify(result), {
      headers: { 'Content-Type': 'application/json' },
    });
  },
};

Notice the handler exports a fetch method, not handler. This follows Bun’s Web API approach. Lambda events are converted to standard Request objects, and your handler returns Response objects.

Deploying with AWS CDK

import { Function, Runtime, Code, LayerVersion, Architecture } from 'aws-cdk-lib/aws-lambda';

// Reference the published Bun layer
const bunRuntimeLayer = LayerVersion.fromLayerVersionArn(
  this,
  'BunRuntime',
  'arn:aws:lambda:us-east-1:123456789012:layer:bun-runtime:1'
);

const bunFunction = new Function(this, 'BunFunction', {
  runtime: Runtime.PROVIDED_AL2023,
  handler: 'index.fetch',
  code: Code.fromAsset('dist'),
  layers: [bunRuntimeLayer],
  architecture: Architecture.X86_64, // Must match layer architecture
});

Critical requirement: The layer architecture must match the function architecture. Build separate layers for x86_64 and arm64 if you need both.

Implementation Approach 2: Container Images

Container images provide full control over the runtime environment and enable advanced optimizations. This approach uses the AWS Lambda Web Adapter to convert HTTP servers into Lambda-compatible handlers.

Bun Container Deployment

# Multi-stage build for Bun Lambda deployment
FROM public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 AS aws-lambda-adapter
FROM oven/bun:1-debian AS runtime

# Copy Lambda adapter
COPY --from=aws-lambda-adapter /lambda-adapter /opt/extensions/lambda-adapter

WORKDIR /var/task

# Install dependencies
COPY package.json bun.lock ./
RUN bun install --production --frozen-lockfile

# Copy application
COPY . .

# Required Lambda adapter configuration
ENV PORT=8080

CMD ["bun", "run", "index.ts"]

The Lambda adapter intercepts incoming Lambda events, converts them to HTTP requests to your server on port 8080, then converts responses back to Lambda format.

Deno with Cache Pre-warming

Deno’s architecture caches module resolution and compilation. Pre-running the application during the Docker build populates these caches:

FROM public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 AS adapter
FROM denoland/deno:bin-2.6.3 AS deno-bin
FROM debian:bookworm-slim

# Install Deno
COPY --from=deno-bin /deno /usr/local/bin/deno
COPY --from=adapter /lambda-adapter /opt/extensions/lambda-adapter

WORKDIR /var/task
ENV DENO_DIR=/var/deno_dir

# Copy application
COPY . .

# Critical: Pre-warm Deno caches
# This runs the app once during build to populate runtime caches
RUN timeout 10s deno run --allow-net main.ts || [ $? -eq 124 ] || exit 1

ENV PORT=8080
CMD ["deno", "run", "--allow-net", "main.ts"]

The timeout 10s command runs the application during build, letting Deno cache all module resolution and compilation. Exit code 124 (timeout) is expected and acceptable; the goal here is a populated cache, not a running server.

Building and Deploying Container Images

# Build for correct architecture (critical on Apple Silicon)
docker build \
  --platform linux/amd64 \
  --provenance=false \
  -t bun-lambda:latest .

# Authenticate to ECR
aws ecr get-login-password --region us-east-1 | \
  docker login --username AWS --password-stdin ${ECR_URI}

# Tag and push
docker tag bun-lambda:latest ${ECR_URI}:latest
docker push ${ECR_URI}:latest

# Create Lambda function
aws lambda create-function \
  --function-name bun-container-function \
  --package-type Image \
  --code ImageUri=${ECR_URI}:latest \
  --role arn:aws:iam::123456789012:role/lambda-role

Platform specification is critical: Lambda defaults to x86_64, but Docker on Apple Silicon defaults to arm64. Always specify --platform linux/amd64 unless you’re using arm64 Lambda functions.

Performance Benchmarks

A CPU-bound benchmark (SHA3-512 hash generation, 50 iterations) produces the following spread across the three runtimes.

Cold Start Times (Initialization Duration)

RuntimeAveragep10p90Range
Node.js (managed)152ms146ms160ms~14ms
Deno (container)267ms185ms297ms~30ms
Bun (layer)548ms500ms603ms~56ms

Node.js initializes about 76% faster than the Deno container and 260% faster than the Bun layer. The gap is AWS’s own initialization work on managed runtimes, and the layer approach pays the most for routing around it.

Warm Invocation Duration

RuntimeAveragep50p90
Deno13.7ms6.7ms19.8ms
Node.js21.3ms8.1ms56.7ms
Bun50.5ms15.2ms68.2ms

Warm invocations flip the ranking. Deno averages 36% below Node.js and keeps the tighter p90, even though it runs as a container-based custom runtime. Bun trails on this workload.

Cost Analysis

Cost implications for a typical workload: 10 million invocations per month, 512MB memory, 100ms average duration, 10% cold start rate. The rates below are the standard x86 on-demand prices for duration and requests.

Node.js (Managed Runtime)

Compute: 10M × 0.0000000083 × 100ms = $8.30
Requests: 10M × 0.0000002 = $2.00
Total: $10.30/month

Bun (Custom Layer)

Assuming 50ms faster execution but 500ms slower cold starts:

Cold start overhead: 1M × 500ms × 0.0000000083 = $4.15
Compute savings: 10M × 50ms × 0.0000000083 = $4.15 saved
Net effect: Approximately equal to Node.js
Trade-off: Worse user experience during cold starts

Deno (Container)

Assuming 115ms slower cold starts but 35% faster warm execution:

Cold start overhead: 1M × 115ms × 0.0000000083 = $0.95
Compute savings: 9M × 35ms × 0.0000000083 = $2.62 saved
Net savings: ~$1.67/month

Decision factors:

  • High steady traffic favors faster warm invocations (Deno)
  • Frequent cold starts favor the Node.js managed runtime
  • CPU-intensive functions benefit more from runtime performance
  • I/O-bound workloads, the majority of Lambda functions, see minimal runtime impact

Common Pitfalls

Platform Architecture Mismatch

Building container images for the wrong CPU architecture causes cryptic runtime errors.

Symptom:

Error: Runtime exited with error: exit status 1
Runtime.InvalidEntrypoint

Root cause: Lambda defaults to x86_64, but Docker on Apple Silicon defaults to arm64.

Solution:

# Always specify platform in build
docker build --platform linux/amd64 -t myfunction .

# Verify built image
docker inspect myimage:latest | grep Architecture
# Should output: "Architecture": "amd64"

Missing Lambda Adapter Configuration

Container runs locally but fails on Lambda with connection errors.

Symptom: Function times out or returns 502 Bad Gateway.

Root cause: Lambda adapter requires PORT environment variable set to 8080.

Correct implementation:

ENV PORT=8080
CMD ["bun", "run", "server.ts"]
// Use environment variable in application
const port = process.env.PORT || 3000;

Bun.serve({
  port: Number(port),
  fetch(request) {
    return new Response('Hello World');
  }
});

AWS SDK Compatibility Issues

Earlier Bun versions had AWS SDK compatibility challenges including Could not resolve: 'http2' errors and SignatureDoesNotMatch errors with S3. Recent versions have improved significantly, but always test AWS SDK operations explicitly in your specific use case:

// test/aws-sdk.test.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { describe, test, expect } from 'bun:test';

describe('AWS SDK Compatibility', () => {
  test('S3 PutObject works', async () => {
    const client = new S3Client({ region: 'us-east-1' });
    const result = await client.send(new PutObjectCommand({
      Bucket: 'test-bucket',
      Key: 'test.txt',
      Body: 'test content'
    }));
    expect(result.$metadata.httpStatusCode).toBe(200);
  });
});

Pin Bun version in Dockerfile:

# Use specific version tag for stability
FROM oven/bun:1-debian

Lambda Layer Architecture Mismatch

Problem: Layer deploys successfully but function fails with “Runtime not supported” error.

Solution: Build and publish layers for both architectures:

# Build for x86_64
ARCH=x64 bun run publish-layer
# Output: arn:aws:lambda:us-east-1:123:layer:bun-x64:1

# Build for arm64
ARCH=arm64 bun run publish-layer
# Output: arn:aws:lambda:us-east-1:123:layer:bun-arm64:1

Match architecture between layer and function in CDK:

import { Architecture } from 'aws-cdk-lib/aws-lambda';

const bunLayerX64 = LayerVersion.fromLayerVersionArn(
  this, 'BunLayerX64',
  'arn:aws:lambda:us-east-1:123:layer:bun-x64:1'
);

new Function(this, 'MyFunction', {
  architecture: Architecture.X86_64,
  layers: [bunLayerX64], // Must match
});

Production-Ready Implementation Patterns

Pattern 1: Deno with HTTP Server + Lambda Adapter

Here’s what works well for API workloads:

// main.ts - Deno with oak framework
import { Application } from "https://deno.land/x/[email protected]/mod.ts";

const app = new Application();

app.use((ctx) => {
  ctx.response.body = { message: "Hello from Deno on Lambda!" };
});

const port = parseInt(Deno.env.get("PORT") || "8080");
console.log(`Server running on port ${port}`);
await app.listen({ port });
# Optimized Dockerfile
FROM public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 AS adapter
FROM denoland/deno:bin-2.6.3 AS deno-bin
FROM debian:bookworm-slim

# Minimal dependencies
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*

# Copy binaries
COPY --from=deno-bin /deno /usr/local/bin/deno
COPY --from=adapter /lambda-adapter /opt/extensions/lambda-adapter

WORKDIR /var/task
ENV DENO_DIR=/var/deno_dir PORT=8080

# Application
COPY . .

# Pre-warm cache (critical optimization)
RUN timeout 10s deno run -A main.ts || [ $? -eq 124 ] || exit 1

CMD ["deno", "run", "-A", "main.ts"]

The pre-warmed cache is what keeps Deno initialization near the low end of the range measured above. The Lambda adapter leaves the handler as a plain HTTP server, so the same code runs locally without a Lambda shim, and TypeScript needs no build step.

Pattern 2: Hybrid Approach - Runtime per Workload

Use the runtime that fits each function type. The selection collapses to four cases:

Function profileRuntime
Cold start sensitive and AWS SDK heavyNode.js (managed)
CPU-bound with steady warm trafficBun
Background work, TypeScript-first, predictable trafficDeno (container)
Everything elseNode.js (managed)

Architecture example:

  • API Gateway endpoints: Node.js (I/O-bound, cold start sensitive)
  • Image processing: Bun container (CPU-intensive, high memory)
  • Scheduled tasks: Deno container (TypeScript-native, predictable traffic)

Alternative Approaches to Consider

Optimize Node.js First

Before switching runtimes, consider Node.js optimizations:

// Bad: initialization in handler
export async function handler(event: APIGatewayEvent) {
  const db = await createDatabaseConnection(); // Cold start penalty
  // ...
}

// Good: initialization at module level
const db = await createDatabaseConnection(); // Outside handler

export async function handler(event: APIGatewayEvent) {
  // Use pre-initialized db
}

ES Modules for tree shaking:

// Old: CommonJS imports entire module
const AWS = require('aws-sdk');

// New: ES Modules import only needed code
import { S3Client } from '@aws-sdk/client-s3';

These changes often deliver similar gains without the complexity of a runtime switch.

Evaluate Rust or Go for Maximum Performance

For CPU-bound workloads, compiled languages outperform every JavaScript runtime, and Lambda runs them through the same provided.al2023 custom runtime interface described above.

Trade-offs:

  • Faster execution and lower memory use per invocation
  • A different language means a skills investment for the team
  • Longer compilation times, less flexible for rapid iteration

Choosing the Runtime

The managed Node.js runtime holds as the default for I/O-bound functions behind API Gateway, for bursty traffic, and for code that leans on the AWS SDK. AWS tunes initialization for its own runtimes and patches them without your involvement. The surrounding tooling assumes Node.js as well.

Override that default when a function is CPU-bound, runs on traffic steady enough to keep execution environments warm, and has a cold start budget you have measured. Container images are the stronger option there, because they allow cache pre-warming. The price is base image patching, longer deployments, and ECR storage. Layers deploy faster and can be shared across functions, but they carry the heaviest initialization overhead, sit under a 250MB uncompressed limit, and force you to match architectures. Between the two runtimes, Bun is the harder sell today: it showed the highest initialization cost here, and there is less production troubleshooting material to lean on.

Before switching either way, exhaust the Node.js options: module-level initialization, ES module imports, and provisioned concurrency. None of them add operational surface. If a proof of concept still favours an alternative runtime, keep it on one non-critical function and compare initialization duration and cold start rate against the Node.js baseline. Test locally with the Lambda Runtime Interface Emulator before trusting any cold start number.

References

Related posts