Skip to main content

Best Serverless Frameworks for Node.js in 2026

·PkgPulse Team
0

TL;DR

SST for full-stack TypeScript on AWS; Serverless Framework for multi-cloud legacy deployments. SST (~200K weekly downloads) is the modern TypeScript-first framework that deploys to AWS with live function development, type-safe resource binding, and a growing ecosystem. Serverless Framework (~2M downloads) is the older multi-provider tool with thousands of plugins. AWS CDK (~500K) is AWS's official infrastructure-as-code with full TypeScript. For new AWS-native apps in 2026, SST is the compelling choice.

Key Takeaways

  • Serverless Framework: ~2M weekly downloads — multi-provider, 1K+ plugins, widely deployed
  • SST: ~200K downloads — TypeScript-first, live Lambda dev, resource binding, AWS-native
  • AWS CDK: ~500K downloads — low-level AWS infrastructure, TypeScript/Python/Java
  • SST v3 — Ion release uses Pulumi under the hood, faster deployments
  • Local development — SST live lambda lets you test against real AWS services instantly

Why Serverless

Serverless architecture means deploying functions that scale automatically, with no server management. You pay only when functions run (pay-per-invocation), scale to zero when idle (no charges when no traffic), and don't patch OS or runtime security vulnerabilities.

The economics are compelling for certain workloads: a startup API that handles 100K requests/month costs around $0.20 on Lambda vs $30-50/month for the smallest always-on EC2 instance. For event-driven workloads (webhooks, queue processors, scheduled tasks), serverless is almost always the right economics.

The trade-offs: cold starts (first invocation after idle may take 100-500ms), 15-minute execution limit for Lambda, and vendor lock-in. For latency-sensitive APIs or long-running processes, traditional servers or containers are often better.


Serverless Framework

# serverless.yml — multi-provider configuration
service: my-api

provider:
  name: aws
  runtime: nodejs20.x
  region: us-east-1
  stage: ${opt:stage, 'dev'}
  environment:
    DATABASE_URL: ${ssm:/myapp/${self:provider.stage}/database-url}
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - dynamodb:GetItem
            - dynamodb:PutItem
            - dynamodb:DeleteItem
          Resource: !GetAtt UsersTable.Arn

functions:
  createUser:
    handler: src/users/create.handler
    events:
      - httpApi:
          path: /users
          method: POST
  getUser:
    handler: src/users/get.handler
    events:
      - httpApi:
          path: /users/{id}
          method: GET
  processQueue:
    handler: src/queue/processor.handler
    events:
      - sqs:
          arn: !GetAtt ProcessingQueue.Arn
          batchSize: 10

resources:
  Resources:
    UsersTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:service}-${self:provider.stage}-users
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH

plugins:
  - serverless-offline          # Local dev
  - serverless-esbuild          # TypeScript build
  - serverless-prune-plugin     # Clean old deployments
// src/users/create.ts — Lambda handler
import type { APIGatewayProxyHandler } from 'aws-lambda';
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import { z } from 'zod';

const db = new DynamoDBClient({});

const schema = z.object({
  name: z.string(),
  email: z.string().email(),
});

export const handler: APIGatewayProxyHandler = async (event) => {
  try {
    const body = schema.parse(JSON.parse(event.body ?? '{}'));

    await db.send(new PutItemCommand({
      TableName: process.env.USERS_TABLE,
      Item: {
        id: { S: crypto.randomUUID() },
        name: { S: body.name },
        email: { S: body.email },
        createdAt: { S: new Date().toISOString() },
      },
    }));

    return { statusCode: 201, body: JSON.stringify({ success: true }) };
  } catch (err) {
    return { statusCode: 400, body: JSON.stringify({ error: String(err) }) };
  }
};

SST v3 (Modern AWS)

// sst.config.ts — TypeScript infrastructure
import { SSTConfig } from 'sst';
import { Api, Table, Bucket, NextjsSite, Cron } from 'sst/constructs';

export default {
  config(input) {
    return {
      name: 'my-app',
      region: 'us-east-1',
    };
  },
  stacks(app) {
    app.stack(function API({ stack }) {
      const table = new Table(stack, 'Users', {
        fields: { id: 'string', email: 'string' },
        primaryIndex: { partitionKey: 'id' },
        globalIndexes: {
          EmailIndex: { partitionKey: 'email' },
        },
      });

      const bucket = new Bucket(stack, 'Uploads');

      const api = new Api(stack, 'Api', {
        routes: {
          'POST /users': 'packages/functions/src/users/create.handler',
          'GET /users/{id}': 'packages/functions/src/users/get.handler',
          'POST /upload': 'packages/functions/src/upload.handler',
        },
        // Type-safe resource binding!
        bind: [table, bucket],
      });

      // Next.js site with SSR
      const site = new NextjsSite(stack, 'Web', {
        path: 'packages/web',
        bind: [api, table],
      });

      // Cron job
      new Cron(stack, 'Cleanup', {
        schedule: 'rate(1 day)',
        job: { function: 'packages/functions/src/cleanup.handler' },
      });

      stack.addOutputs({
        ApiUrl: api.url,
        SiteUrl: site.url,
      });
    });
  },
} satisfies SSTConfig;
// SST resource binding — type-safe, no env vars needed
// packages/functions/src/users/create.ts
import { Table } from 'sst/node/table';
import { Bucket } from 'sst/node/bucket';
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

// SST injects the correct table name/bucket name at runtime
// Table.Users.tableName — automatically bound
// Bucket.Uploads.bucketName — automatically bound
const db = new DynamoDBClient({});

export const handler = async (event) => {
  await db.send(new PutItemCommand({
    TableName: Table.Users.tableName,  // Type-safe, no process.env string
    Item: { /* ... */ },
  }));
};
# SST commands
npx sst dev              # Start live Lambda dev (changes deploy instantly)
npx sst deploy           # Deploy to AWS
npx sst deploy --stage prod  # Deploy to production
npx sst remove           # Tear down all resources
npx sst console          # Open SST Console (logs, DynamoDB viewer, etc.)

SST's sst dev is its standout feature. When you run it, your Lambda functions run locally but are connected to real AWS services (DynamoDB, S3, SQS, etc.). When a real request hits your deployed Lambda URL, SST tunnels it to your local machine, your code runs locally, and the response goes back. Changes you make in your editor are reflected instantly — no deployment cycle needed for development.


Cold Start Optimization

Cold starts are the main performance concern for serverless APIs:

// Techniques to reduce cold start latency

// 1. Keep Lambda warm with scheduled invocations
// In serverless.yml:
functions:
  keepWarm:
    handler: src/warmup.handler
    events:
      - schedule:
          rate: rate(5 minutes)
          input: { warmup: true }

// 2. Use Node.js bundling to reduce package size
// Large node_modules = slower cold starts
// Bundle with esbuild to reduce to a single file
// serverless-esbuild or esbuild in SST does this automatically

// 3. Use Lambda SnapStart (Java) or initialize outside handler
// Move DB connections outside the handler function:
const db = new DynamoDBClient({});  // Created once, reused across invocations

export const handler = async (event) => {
  // db is already initialized — no connection overhead
  return await db.send(/* ... */);
};

Cold start times in 2026:

  • Node.js 20 (default): 100-300ms for a simple Lambda with small bundle
  • Lambda with large dependencies (AWS SDK v2 full package): 500ms-1.5s
  • AWS SDK v3 with modular imports: 100-400ms (much smaller bundle than v2)
  • Lambda SnapStart (Java only, not Node.js): <50ms

For Node.js Lambdas serving HTTP traffic, the esbuild bundling approach (SST does this automatically) is the most effective optimization — bundle the application into a single ~1-5MB file and import only what you use from the AWS SDK.


Cost Comparison

Lambda pricing (us-east-1, arm64):
  $0.0000000133 per GB-second
  $0.20 per 1M requests

Example: 1M requests/month, 256MB, 100ms average:
  Compute: 1,000,000 × 0.256 × 0.1 × $0.0000000133 = $0.34
  Requests: 1,000,000 × $0.0000002 = $0.20
  Total: ~$0.54/month

Comparison: smallest EC2 t4g.nano: $3.07/month (with no traffic savings)

At low to medium traffic, Lambda is almost always cheaper than reserved EC2. The break-even point depends on your function duration and memory requirements — for high-traffic APIs with long-running operations, EC2 or containers can become cheaper.


When to Choose

ScenarioPick
New AWS project, TypeScriptSST
Multi-cloud (AWS + GCP + Azure)Serverless Framework
Complex AWS infrastructureAWS CDK
Legacy Serverless Framework projectKeep SF (migration not worth it)
Full-stack (Lambda + Next.js + DynamoDB)SST
Need Serverless ConsoleServerless Framework
Fine-grained AWS controlAWS CDK

Compare serverless framework package health on PkgPulse. Also see how to set up CI/CD for a JavaScript monorepo for deploying from CI and best Node.js logging libraries for observability in Lambda functions.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.