.NET runs well on AWS Lambda once it’s warm. The problem is the first request that lands on a new execution environment: before your handler runs, Lambda has to start the runtime, load your assemblies, JIT-compile the code on the hot path, build the dependency injection container and create your SDK clients. That’s the cold start, and for .NET it’s usually longer than for interpreted languages.
The tools to fight it keep improving. The managed dotnet10 runtime has been available since January 2026, with support for SnapStart, and the Native AOT templates now target .NET 10 too. Here’s how I’d approach it.
Measure before you optimize
Every cold start writes an Init Duration to the REPORT line that Lambda logs after an invocation. CloudWatch Logs Insights exposes it as @initDuration, so one query tells you how often cold starts happen and how long they take:
filter @type = "REPORT" and ispresent(@initDuration)
| stats count() as coldStarts,
pct(@initDuration, 50) as p50,
pct(@initDuration, 95) as p95
by bin(1h)
Run it before and after each change. Cold starts only hurt if they’re frequent enough, or slow enough, to show up in the latency your users see.
Cheap fixes first
These cost little and help whatever you decide later:
- Give the function more memory. Lambda allocates CPU in proportion to memory, and the init phase is CPU-bound. A function with 1,024 MB often starts much faster than the same code with 256 MB, and may not cost more, since it also finishes sooner.
- Do less at startup. Create SDK clients and
HttpClients once, outside the handler, so warm invocations reuse them. But don’t load data or open connections the first request might not need. - Precompile with ReadyToRun.
<PublishReadyToRun>true</PublishReadyToRun>ships ahead-of-time compiled code next to the IL, so the JIT has less to do at startup. It makes the package bigger and has to be built for the target platform (linux-x64orlinux-arm64), but it’s a flag, not a rewrite. - Prefer source-generated JSON. Reflection-based serialization pays a warm-up cost on first use.
System.Text.Jsonsource generation moves that work to compile time, and you’ll need it anyway if you go to Native AOT.
SnapStart: skip the init phase
With SnapStart, Lambda runs your init code once, when you publish a version. It then takes a snapshot of the execution environment’s memory and disk, and caches it. New execution environments resume from that snapshot instead of starting from scratch. You keep the regular managed runtime and your existing code, reflection and all.
In a SAM template it’s two properties: SnapStart itself, and an alias so that every deployment publishes a version:
Resources:
OrdersFunction:
Type: AWS::Serverless::Function
Properties:
Runtime: dotnet10
Handler: Orders::Orders.Function::Handle
MemorySize: 1024
AutoPublishAlias: live
SnapStart:
ApplyOn: PublishedVersions
The snapshot captures whatever your code has done by the time init ends. Two runtime hooks in Amazon.Lambda.Core let you control that. Before the snapshot, run the hot path so it’s already JIT-compiled in the snapshot. After a restore, regenerate anything that must be unique:
using System.Text.Json;
using Amazon.Lambda.Core;
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
namespace Orders;
public record Order(string Id, decimal[] Items);
public record Receipt(string OrderId, decimal Total, string ProcessedBy);
public class Function
{
private static string _environmentId = Guid.NewGuid().ToString("N");
public Function()
{
SnapshotRestore.RegisterBeforeSnapshot(() =>
{
// Run the hot path (JSON and pricing) so it's JIT-compiled
// before the snapshot. No calls to other systems here.
var sample = JsonSerializer.Deserialize<Order>("""{"Id":"warm-up","Items":[1.5,2]}""")!;
for (var i = 0; i < 10; i++)
JsonSerializer.Serialize(Process(sample));
return ValueTask.CompletedTask;
});
SnapshotRestore.RegisterAfterRestore(() =>
{
// Every environment restored from the snapshot starts with the
// same memory: regenerate anything that must be unique.
_environmentId = Guid.NewGuid().ToString("N");
return ValueTask.CompletedTask;
});
}
public Receipt Handle(Order order, ILambdaContext context) => Process(order);
private static Receipt Process(Order order) =>
new(order.Id, order.Items.Sum(), _environmentId);
}
That second hook is the part people miss. Many environments can be restored from the same snapshot, so anything created during init is identical in all of them: IDs, random number generators seeded at startup, cached credentials, even open network connections, which may be stale when the environment resumes. Create those after the restore, or lazily on first use.
The trade-offs:
- SnapStart only applies to published versions and the aliases that point to them, never to
$LATEST. - It can’t be combined with provisioned concurrency, Amazon EFS, or ephemeral storage above 512 MB.
- Unlike SnapStart for Java, the .NET version isn’t free: you pay to cache the snapshot while the version is active and for each restore. The pricing page has the numbers for your Region.
- After-restore hooks must finish within the restore timeout, so keep them short.
Advertisement
Native AOT: no JIT at all
Native AOT compiles your function into a self-contained native executable. There’s no JIT and no IL to load, so it starts fast and uses less memory. The official Lambda template turns it on with a few properties:
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<PublishAot>true</PublishAot>
<StripSymbols>true</StripSymbols>
<TrimMode>partial</TrimMode>
</PropertyGroup>
Because the output is an executable, the function has a Main that starts the Lambda runtime client itself. JSON goes through a source-generated serializer context instead of reflection:
using System.Text.Json.Serialization;
using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
Func<Order, ILambdaContext, Receipt> handler = (order, context) =>
new Receipt(order.Id, order.Items.Sum());
await LambdaBootstrapBuilder
.Create(handler, new SourceGeneratorLambdaJsonSerializer<OrdersJsonContext>())
.Build()
.RunAsync();
public record Order(string Id, decimal[] Items);
public record Receipt(string OrderId, decimal Total);
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(Receipt))]
public partial class OrdersJsonContext : JsonSerializerContext;
The price is paid at build time and in your dependencies:
- Everything must survive trimming. Code that relies on reflection, such as some serializers, DI conventions and older libraries, can fail at runtime with missing types. Treat every trim or AOT warning as a bug. The template uses
TrimModepartialbecause the AWS SDK for .NET isn’t fully trim-safe. - You build for the target platform. A native executable is tied to its OS and architecture, so it must be built on Amazon Linux 2023. The Lambda tooling (
Amazon.Lambda.Tools, SAM) does that in a container, which means Docker on your machine and in CI. - Builds are slower. Native compilation takes much longer than a regular build.
Or pay to keep them warm
Provisioned concurrency keeps a number of environments initialized and ready. There’s no cold start for traffic within that number, but you pay for those environments whether or not they’re used. It suits steady, latency-critical workloads, and it can’t be combined with SnapStart.
Which one when
| Situation | What I’d reach for |
|---|---|
| Cold starts are rare, or a few hundred milliseconds don’t matter | The cheap fixes: more memory, ReadyToRun, less work at startup |
| An existing function with ASP.NET Core, EF Core or reflection-heavy libraries | SnapStart, with warm-up and after-restore hooks |
| A new, focused function with few dependencies | Native AOT |
| Steady traffic with a strict latency target | Provisioned concurrency |
Whatever you choose, go back to the @initDuration query afterwards. It’s the only way to know the change was worth it.


