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_TYPEtells you how an environment was started:provisioned-concurrencyoron-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
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
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"
}
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node", "aws-lambda"]
},
"include": ["src"]
}
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 }),
};
};
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
A few details that are easy to overlook:
-
DependsOn: MenuProvConcLambdaAliasmakes sure the alias exists before the target is registered. Without this dependency, the first deploy occasionally fails. -
ResourceIdis built from service, stage and function key, which is how the framework names functions by default. If you have overriddennameon the function, this value has to follow. -
RoleARNpoints 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
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
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
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
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
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 });
}
}
// 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' } });
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
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
- AWS Lambda Developer Guide: Configuring provisioned concurrency
- AWS Lambda Pricing: Provisioned Concurrency Pricing
- Application Auto Scaling: Scheduled scaling
- Application Auto Scaling: Create scheduled actions (with time zone)
- Application Auto Scaling: Schedule recurring scaling actions
- CloudFormation: ScalableTarget ScheduledAction
- AWS Compute Blog: Scheduling AWS Lambda Provisioned Concurrency for recurring peak usage
- AWS re:Post: Troubleshoot scaling issues in Application Auto Scaling
- Serverless Framework Docs: Functions (provisionedConcurrency)
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)