DEV Community

Cover image for Scheduled provisioned concurrency for AWS Lambda
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Scheduled provisioned concurrency for AWS Lambda

Scheduled provisioned concurrency is, in my view, the cleanest answer to cold starts when your traffic follows a fixed rhythm. Mornings get busy, lunchtime brings a peak, and evenings are quiet. So why pay for pre-warmed Lambda environments around the clock?

In my article on how to reduce Lambda cold starts, I only touched on this in a single paragraph. Today I build the whole thing: with the Serverless Framework first, then the same setup in AWS CDK, including a cost estimate, verification commands and the mistakes people typically make along the way.

In short

With Application Auto Scaling you decide how many Lambda environments are warm at which time of day, and you only pay the full price in those windows. What matters is an alias instead of $LATEST, a time zone on every scheduled action and a lowered maximum to scale back down. The biggest pitfall: your API has to invoke the alias, otherwise the warm environments sit idle.

What you will learn in this article

  • how alias, Application Auto Scaling and scheduled actions work together
  • how to build scheduled provisioned concurrency with the Serverless Framework
  • how to do the same in AWS CDK with scaleOnSchedule
  • how to size capacity and estimate the cost up front
  • how to confirm with three CLI commands that the schedule really applies

What is provisioned concurrency?

In AWS Lambda, concurrency is the number of requests a function handles at the same time. Each of them runs in its own execution environment. If no free environment is available, Lambda starts a new one: it loads the runtime, imports your code and creates your clients. This init phase is the cold start.

Provisioned concurrency keeps a fixed number of these environments ready in advance. Your init code runs while they are being prepared, before the first request arrives. According to AWS, a function prepared this way responds within double-digit milliseconds. These points matter in practice:

  • Provisioned concurrency is always attached to a published version or an alias, never to $LATEST.
  • Once all prepared environments are busy, Lambda serves additional requests on demand as usual, so cold starts can happen again.
  • The environment variable AWS_LAMBDA_INITIALIZATION_TYPE tells you how an environment was started: provisioned-concurrency or on-demand. We will use that later for verification.

Reserved concurrency is a different thing. It sets aside part of the account quota for one function and caps it at the same time, at no extra cost. Nothing gets pre-warmed there.

Why scheduled provisioned concurrency instead of 24/7?

Provisioned concurrency costs money as long as it is configured, even if not a single request comes in. AWS bills it in GB-seconds: configured memory times number of environments times duration, billed in full five-minute blocks. The free tier does not apply to such functions. In return, the price per executed GB-second is lower than on demand.

This article uses a fictional canteen app as its example. Employees check the menu in the morning, almost everyone orders between 10:45 and 13:45, and after 6 pm hardly anything happens. The function has 1,024 MB of memory. During the lunch peak, 15 environments should be warm.

Option Environment hours per month Provisioned concurrency share
15 environments around the clock approx. 10,920 approx. 164 US dollars
Schedule: 3 / 15 / 3 on weekdays, otherwise 1 approx. 1,963 approx. 29 US dollars

I used the price from the AWS examples for US East (N. Virginia) on x86: 0.0000041667 US dollars per GB-second, which is about 1.5 cents per environment and hour at 1 GB. Arm functions are slightly cheaper, other regions slightly more expensive. Requests and execution time come on top in both options. That barely changes the ratio, though: here the schedule saves more than 80 percent of the base price.

When a schedule does not pay off

If your traffic follows no pattern, a schedule will not help. That is what target tracking is for, a policy that adjusts capacity based on utilization. Asynchronous processing, such as queues or nightly batch jobs, also rarely needs provisioned concurrency, because nobody is waiting for a response. And if cold starts only cost 200 milliseconds after optimization, the honest question is: does anyone even notice?

How alias, Application Auto Scaling and the schedule work together

Flow: the schedule sets warm environments on the alias via Application Auto Scaling, and the HTTP API invokes the alias

Schedule, Application Auto Scaling and alias: how requests reach warm environments.

Application Auto Scaling is the AWS service that manages capacity for other services, among them DynamoDB, ECS and Lambda. According to AWS, the service itself costs nothing extra. You register your function alias as a scalable target with the dimension lambda:function:ProvisionedConcurrency. The resource ID has the form function:<function-name>:<alias>.

You then attach scheduled actions to this target. Each action sets a new minimum, a new maximum or both at a given time. The logic behind it is important:

  • Is the current capacity lower than the new minimum? Then the service adds environments until the minimum is reached.
  • Is it higher than the new maximum? Then it removes environments down to the maximum.
  • Anything in between stays as it is.

That is why I set minimum and maximum to the same value in every action. The capacity after the action is then unambiguous, no matter what it was before. Otherwise, if you only lower the minimum, you will wonder why 15 environments are still running in the evening.

The schedule uses cron expressions with six fields: minute, hour, day of month, month, day of week, year. Without further input, UTC applies. With the Timezone field and an IANA time zone such as Europe/Berlin, the times refer to local time. The syntax matches EventBridge, so you can double-check your expressions with the EventBridge cron explainer.

🎬 Provisioned concurrency over a working day with four scheduled actions – this animated diagram only runs on my site: watch it on sascha-ms.de

Step by step: scheduled provisioned concurrency with the Serverless Framework

We start with the Serverless Framework, because it creates the alias out of the box. At the end you have an HTTP API with a function called menu whose warm environments change by time of day on weekdays.

Prerequisites

  • Node.js 24 and npm
  • Serverless Framework v4 (npm i -g serverless) with a login or license key, as v4 requires
  • an AWS account with permissions for Lambda, API Gateway, CloudFormation, IAM and Application Auto Scaling
  • AWS CLI v2 for the verification commands

Step 1: Create the project

mkdir canteen-menu && cd canteen-menu
npm init -y
npm i -D typescript @types/node @types/aws-lambda
mkdir src
Enter fullscreen mode Exit fullscreen mode

Next, replace the package.json with this version and create the tsconfig.json. TypeScript 7 only includes type packages that are listed under types, so that line matters.

{
  "name": "canteen-menu",
  "private": true,
  "type": "module",
  "scripts": {
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.164",
    "@types/node": "^24.19.2",
    "typescript": "^7.0.2"
  }
}
Enter fullscreen mode Exit fullscreen mode
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node", "aws-lambda"]
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Write the handler

The handler returns the menu and also writes into a response header how its environment was started. This lets you see later, without digging through logs, whether a request hit a warm environment.

// src/menu.ts
import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';

// Runs once per execution environment. With provisioned concurrency this
// happens before the first request arrives.
const initType = process.env.AWS_LAMBDA_INITIALIZATION_TYPE ?? 'unknown';
const startedAt = new Date().toISOString();

const todaysMenu = [
  { id: 'm1', name: 'Lentil stew', priceCents: 520 },
  { id: 'm2', name: 'Vegetable lasagne', priceCents: 690 },
  { id: 'm3', name: 'Baked potato with herb quark', priceCents: 480 },
];

export const handler: APIGatewayProxyHandlerV2 = async () => {
  console.log(JSON.stringify({ msg: 'menu requested', initType, startedAt }));
  return {
    statusCode: 200,
    headers: {
      'content-type': 'application/json',
      'x-init-type': initType,
    },
    body: JSON.stringify({ date: new Date().toISOString().slice(0, 10), dishes: todaysMenu }),
  };
};
Enter fullscreen mode Exit fullscreen mode

Step 3: Function with alias in serverless.yml

The key setting is provisionedConcurrency. As soon as it is set, the Serverless Framework publishes a new version on every deploy, creates an alias named provisioned and points the httpApi event at exactly that alias. In the CloudFormation template, the alias is called MenuProvConcLambdaAlias by default: the function name with a capital first letter plus ProvConcLambdaAlias. To be sure, take a quick look at .serverless/cloudformation-template-update-stack.json after running serverless package.

I deliberately set the value to 1. It is the initial value that AWS explicitly recommends for Application Auto Scaling. The schedule takes care of everything else.

Step 4: Add the schedule as a ScalableTarget

The Serverless Framework has no dedicated setting for scheduled scaling. That is not a problem, because you can add any CloudFormation under resources. Here is the complete file:

# serverless.yml
service: canteen-menu
frameworkVersion: '4'

provider:
  name: aws
  runtime: nodejs24.x
  architecture: arm64
  region: eu-central-1
  memorySize: 1024
  timeout: 10

functions:
  menu:
    handler: src/menu.handler
    provisionedConcurrency: 1
    events:
      - httpApi:
          method: GET
          path: /menu

resources:
  Resources:
    MenuConcurrencySchedule:
      Type: AWS::ApplicationAutoScaling::ScalableTarget
      DependsOn: MenuProvConcLambdaAlias
      Properties:
        ServiceNamespace: lambda
        ScalableDimension: lambda:function:ProvisionedConcurrency
        ResourceId: function:${self:service}-${sls:stage}-menu:provisioned
        RoleARN: !Sub arn:aws:iam::${AWS::AccountId}:role/aws-service-role/lambda.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_LambdaConcurrency
        MinCapacity: 1
        MaxCapacity: 20
        ScheduledActions:
          - ScheduledActionName: morning-ramp-up
            Schedule: cron(30 7 ? * MON-FRI *)
            Timezone: Europe/Berlin
            ScalableTargetAction:
              MinCapacity: 3
              MaxCapacity: 3
          - ScheduledActionName: lunch-peak
            Schedule: cron(45 10 ? * MON-FRI *)
            Timezone: Europe/Berlin
            ScalableTargetAction:
              MinCapacity: 15
              MaxCapacity: 15
          - ScheduledActionName: afternoon-ramp-down
            Schedule: cron(45 13 ? * MON-FRI *)
            Timezone: Europe/Berlin
            ScalableTargetAction:
              MinCapacity: 3
              MaxCapacity: 3
          - ScheduledActionName: evening-minimum
            Schedule: cron(0 18 ? * MON-FRI *)
            Timezone: Europe/Berlin
            ScalableTargetAction:
              MinCapacity: 1
              MaxCapacity: 1
Enter fullscreen mode Exit fullscreen mode

A few details that are easy to overlook:

  • DependsOn: MenuProvConcLambdaAlias makes sure the alias exists before the target is registered. Without this dependency, the first deploy occasionally fails.
  • ResourceId is built from service, stage and function key, which is how the framework names functions by default. If you have overridden name on the function, this value has to follow.
  • RoleARN points to the service-linked role of Application Auto Scaling for Lambda. If you specify the full ARN, AWS creates the role automatically the first time.
  • The four actions only run on weekdays. On Friday at 6 pm, capacity drops to 1 and stays there until Monday morning.

Step 5: Deploy and verify

serverless deploy --stage dev
Enter fullscreen mode Exit fullscreen mode

Expect the deploy to take a few minutes longer than usual. CloudFormation waits until the prepared environments are ready. Afterwards, three commands tell you whether everything is right. First, the scheduled actions:

aws application-autoscaling describe-scheduled-actions \
  --service-namespace lambda \
  --resource-id function:canteen-menu-dev-menu:provisioned \
  --query "ScheduledActions[].[ScheduledActionName,Schedule,Timezone]" \
  --output table
Enter fullscreen mode Exit fullscreen mode

You should see four rows with Europe/Berlin. After the first trigger time, the second command shows you what Application Auto Scaling actually did:

aws application-autoscaling describe-scaling-activities \
  --service-namespace lambda \
  --resource-id function:canteen-menu-dev-menu:provisioned \
  --query "ScalingActivities[0:4].[StartTime,Description,StatusCode]" \
  --output table
Enter fullscreen mode Exit fullscreen mode

The third command asks Lambda directly for the current state. During the lunch peak it should show 15 and the status READY:

aws lambda get-provisioned-concurrency-config \
  --function-name canteen-menu-dev-menu \
  --qualifier provisioned
Enter fullscreen mode Exit fullscreen mode

Finally, call the API. serverless deploy prints the URL at the end.

curl -i https://<your-api-id>.execute-api.eu-central-1.amazonaws.com/menu
Enter fullscreen mode Exit fullscreen mode

For a warm environment, the x-init-type header says provisioned-concurrency. If you see on-demand, either everything was busy at that moment or the API does not invoke the alias.

Scheduled provisioned concurrency with AWS CDK

In AWS CDK, the schedule is almost built in. A lambda.Alias gets a scalable target via addAutoScaling, and scaleOnSchedule attaches the scheduled actions to it. The resulting CloudFormation matches the one from serverless.yml. Copy the handler from step 2 unchanged to src/menu.ts.

// lib/canteen-stack.ts
import { Stack, StackProps, TimeZone, CfnOutput } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { HttpApi, HttpMethod } from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import { Schedule } from 'aws-cdk-lib/aws-applicationautoscaling';

export class CanteenStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const menuFn = new NodejsFunction(this, 'MenuFunction', {
      entry: 'src/menu.ts',
      runtime: lambda.Runtime.NODEJS_24_X,
      architecture: lambda.Architecture.ARM_64,
      memorySize: 1024,
    });

    // Provisioned concurrency needs a version or alias, never $LATEST.
    const live = new lambda.Alias(this, 'MenuLive', {
      aliasName: 'live',
      version: menuFn.currentVersion,
      provisionedConcurrentExecutions: 1,
    });

    const scaling = live.addAutoScaling({ minCapacity: 1, maxCapacity: 20 });
    const berlin = TimeZone.EUROPE_BERLIN;
    const weekdays = 'MON-FRI';

    scaling.scaleOnSchedule('MorningRampUp', {
      schedule: Schedule.cron({ hour: '7', minute: '30', weekDay: weekdays }),
      timeZone: berlin,
      minCapacity: 3,
      maxCapacity: 3,
    });
    scaling.scaleOnSchedule('LunchPeak', {
      schedule: Schedule.cron({ hour: '10', minute: '45', weekDay: weekdays }),
      timeZone: berlin,
      minCapacity: 15,
      maxCapacity: 15,
    });
    scaling.scaleOnSchedule('AfternoonRampDown', {
      schedule: Schedule.cron({ hour: '13', minute: '45', weekDay: weekdays }),
      timeZone: berlin,
      minCapacity: 3,
      maxCapacity: 3,
    });
    scaling.scaleOnSchedule('EveningMinimum', {
      schedule: Schedule.cron({ hour: '18', minute: '0', weekDay: weekdays }),
      timeZone: berlin,
      minCapacity: 1,
      maxCapacity: 1,
    });

    // The API must call the alias, otherwise the warm environments stay unused.
    const api = new HttpApi(this, 'CanteenApi');
    api.addRoutes({
      path: '/menu',
      methods: [HttpMethod.GET],
      integration: new HttpLambdaIntegration('MenuIntegration', live),
    });

    new CfnOutput(this, 'ApiUrl', { value: api.apiEndpoint });
  }
}
Enter fullscreen mode Exit fullscreen mode
// bin/app.ts
import { App } from 'aws-cdk-lib';
import { CanteenStack } from '../lib/canteen-stack.js';

const app = new App();
new CanteenStack(app, 'CanteenStack', { env: { region: 'eu-central-1' } });
Enter fullscreen mode Exit fullscreen mode

Besides aws-cdk-lib and constructs, the project needs esbuild so that NodejsFunction can bundle locally, plus tsx to start the app. Put "app": "npx tsx bin/app.ts" into cdk.json. Then:

npm i aws-cdk-lib constructs
npm i -D aws-cdk typescript esbuild tsx @types/node @types/aws-lambda
npx cdk synth
npx cdk deploy
Enter fullscreen mode Exit fullscreen mode

On my machine, cdk synth with aws-cdk-lib 2.275.0 produces exactly the four scheduled actions with Europe/Berlin and the same cron expressions as above. CDK sets the role for Application Auto Scaling automatically.

Sizing capacity for the schedule

How many environments do you need at peak time? AWS gives a simple formula: concurrency equals requests per second times average duration in seconds. You will find both values in CloudWatch in the Invocations and Duration metrics. For the peak value, it is also worth looking at ConcurrentExecutions.

A worked example: at lunchtime, 40 requests per second come in, and each takes 300 milliseconds on average. That gives 40 times 0.3, so 12 concurrent executions. AWS also recommends a buffer of around 10 percent, which puts you at about 14. In cases like this, I round up to 15.

You should know two more limits. First, you can assign at most the unreserved account quota minus 100 to a function, because 100 units must stay available for all other functions. Second, provisioned concurrency must not exceed any reserved concurrency you have set. Also schedule the scale-out action a few minutes before the expected peak, since preparing the environments takes a while. In the example, the lunch action therefore starts at 10:45 instead of 11:00.

Common mistakes with scheduled provisioned concurrency

The API invokes $LATEST instead of the alias

This is the classic one. The environments are paid for and warm, but no request ever reaches them. With the Serverless Framework this does not happen as long as you set provisionedConcurrency on the function. In CDK, you have to pass the alias to the integration explicitly, not the function. The same applies to event sources such as SQS or EventBridge rules.

Forgetting the time zone

Without Timezone, Application Auto Scaling works in UTC. In winter, Berlin is one hour ahead of UTC, in summer two hours. A schedule converted to UTC by hand therefore drifts twice a year. With the IANA time zone in that field, 10:45 stays 10:45 local time in summer as well.

Lowering only the minimum

An action with MinCapacity: 3 and no new maximum does not reduce capacity. Application Auto Scaling only scales in when the current capacity is above the maximum. To scale down, the maximum has to go down.

Deploying in the middle of the peak

Every code deploy creates a new version, and CloudFormation moves the alias to it. The template still contains the initial value 1. Before the first production use, I would test which value actually applies after a deploy at lunchtime. If get-provisioned-concurrency-config shows 1 again afterwards, deploy outside peak hours or set the value right after the deploy with aws lambda put-provisioned-concurrency-config.

Target tracking without traffic

Scheduled actions can be combined with target tracking. In that case, the minimum and maximum of the last action define the window, and the policy scales within it. Keep in mind that Lambda only reports the utilization metric when requests come in. Without traffic, the alarms go to INSUFFICIENT_DATA, and capacity stays where it is. A scheduled scale-in in the evening therefore makes sense even with target tracking.

SnapStart and provisioned concurrency together

You cannot use both on the same function. In that case, the Serverless Framework stops with an error message as early as packaging. Choose one of the two approaches per function. When SnapStart pays off for Python functions is covered in SnapStart for Python.

Keeping an eye on cost and security

Besides the usual permissions, the deploy role needs access to application-autoscaling:* for the target and, the very first time, iam:CreateServiceLinkedRole so that AWS can create the service-linked role. After that, the existing role is enough. You do not have to write any further permissions for Application Auto Scaling yourself.

Because provisioned concurrency costs money even without requests, a budget alarm belongs in the setup. I show how to set up alarms, a dashboard and a budget for Lambda with CDK in Monitoring Lambda: alarms, dashboard and budget. Otherwise, a typo in the schedule, say 150 instead of 15, only shows up on the bill.

My take

I consider scheduled provisioned concurrency one of the underrated tools in everyday Serverless work. It is not a hack like warm-up pings, but an official mechanism with clear billing. At the same time, it forces you to really look at your load profile. That alone often brings more than the setting itself.

Where I disagree is the idea of attaching provisioned concurrency to every API by reflex. The homework comes first: a small bundle, lean init code, the right memory size. Only if users still measurably wait after that and the traffic follows a pattern is it time for a schedule. Then, however, with real numbers instead of gut feeling.

FAQ

How much does provisioned concurrency cost?

You pay per GB-second for the configured capacity while it is active, billed in five-minute increments. In US East, the x86 price is 0.0000041667 US dollars per GB-second. Requests and a reduced charge for execution time come on top.

Can provisioned concurrency go down to 0 at night?

Yes. AWS shows an example on the Compute Blog in which a scheduled action sets minimum and maximum to 0. In my Serverless Framework example, one environment stays warm at night, because the alias is created with the initial value 1. At 1 GB, that costs around 1.5 cents per hour.

Does the schedule handle daylight saving time?

Yes, if you specify an IANA time zone such as Europe/Berlin in the Timezone field. Without this field, UTC applies, and the schedule shifts by one hour with every clock change.

What is the difference between reserved and provisioned concurrency?

Reserved concurrency sets aside part of the account quota and caps the function at the same time, at no extra cost. Provisioned concurrency keeps pre-initialized environments ready and costs extra. You can combine both; provisioned concurrency must then not exceed reserved concurrency.

Can I combine a schedule with target tracking?

Yes. The scheduled action sets the window of minimum and maximum, and the target tracking policy scales within that window based on utilization.

Your next step

Open the ConcurrentExecutions metric of your most important API function in CloudWatch and look at the last two weeks. If you see a recurring pattern, your first schedule is almost done. Which peak would you smooth out first: Monday morning, the lunch break or month-end closing?

Sources

This post was created with AI assistance.


👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.

📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)