Serverless .NET
Write a Lambda or Azure Function in C#, understand cold starts and Native AOT, and know when a container is the better fit.
await LambdaBootstrapBuilder.Create(handler, new DefaultLambdaJsonSerializer()).Build().RunAsync();Serverless means the provider starts your code when something happens and charges per invocation. There is no process you keep running and no machine you keep warm. AWS Lambda and Azure Functions are the two you will meet. You write a method, the platform hands it an event, and what you return becomes the response.
Cold starts
To run your function the platform needs an execution environment: a sandbox with your code in it. Creating one is a cold start. It downloads the code, starts the .NET runtime, and compiles your methods to machine code the first time each one runs, which is what the just-in-time compiler does. Later invocations reuse that environment and skip all of it. So the first request after a quiet spell is the slow one, and the rest are fast.
What Native AOT changes
Native AOT publishes a native executable, compiled ahead of time. There is no JIT work and no runtime to start, which removes most of the cold-start cost. The price is that reflection is limited: JSON has to go through a source-generated JsonSerializerContext, and you must build on Linux matching the runtime, which in practice means building in a container.
When a container service is better
Steady traffic, work longer than Lambda's fifteen-minute limit, WebSocket connections, a large database connection pool, or a strict tail-latency target all argue for a long-running container on ECS or Kubernetes instead.
Example
// dotnet add package Amazon.Lambda.Core
// dotnet add package Amazon.Lambda.RuntimeSupport
// dotnet add package Amazon.Lambda.Serialization.SystemTextJson
using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
// This runs once per cold start, not once per invocation.
string[] allowed = ["GBP", "EUR", "USD"];
// The handler. Its natural delegate type is
// Func<Quote, ILambdaContext, Receipt>, which is what you would write by
// hand instead of var; the serializer turns the event JSON into a Quote.
var handler = (Quote quote, ILambdaContext context) =>
{
context.Logger.LogInformation($"request {context.AwsRequestId}");
if (!allowed.Contains(quote.Currency))
{
// A thrown exception becomes a failed invocation.
throw new ArgumentException($"unsupported currency: {quote.Currency}");
}
return new Receipt(quote.Currency, quote.Amount * 100m);
};
// Starts the runtime loop: ask Lambda for an event, call the handler,
// post the result, repeat. It does not return.
await LambdaBootstrapBuilder.Create(handler, new DefaultLambdaJsonSerializer())
.Build()
.RunAsync();
public record Quote(string Currency, decimal Amount);
public record Receipt(string Currency, decimal Minor);When to use it
- Resize an image whenever a file lands in a bucket, with nothing running the rest of the day.
- Run a nightly report job that costs nothing for the other twenty-three hours.
- Serve a low-traffic internal API where an occasional slow first request is acceptable.
More examples
The same function, ready for Native AOT
The only real change is the serializer. Reach for this when cold start matters: a user-facing endpoint behind an occasional request, rather than a nightly batch job where a second of startup is irrelevant.
using System.Text.Json.Serialization;
using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
var handler = (Quote quote, ILambdaContext context) =>
new Receipt(quote.Currency, quote.Amount * 100m);
// SourceGeneratorLambdaJsonSerializer uses code generated at build time
// instead of reflection, which is what a trimmed native binary needs.
await LambdaBootstrapBuilder
.Create(handler, new SourceGeneratorLambdaJsonSerializer<QuoteContext>())
.Build()
.RunAsync();
public record Quote(string Currency, decimal Amount);
public record Receipt(string Currency, decimal Minor);
// One JsonSerializable attribute per type that crosses the boundary.
// Miss one and serialization fails at run time, not at build time.
[JsonSerializable(typeof(Quote))]
[JsonSerializable(typeof(Receipt))]
public partial class QuoteContext : JsonSerializerContext
{
}The project file that produces the native binary
A native build is tied to the operating system and processor architecture it was produced on, so publish it inside a Linux container that matches the Lambda runtime rather than from a Mac or Windows machine. Check which managed .NET runtimes Lambda currently offers; where the version you want is not listed, a native binary on a custom runtime or a container image is how you ship it.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Compile to a native executable instead of IL run by the JIT. -->
<PublishAot>true</PublishAot>
<!-- Smaller upload; keep the symbols if you want readable stack traces. -->
<StripSymbols>true</StripSymbols>
</PropertyGroup>
</Project>An HTTP-triggered Azure Function
Azure Functions in the isolated worker model run in their own .NET process, so you choose the .NET version independently of the Functions host. The trigger attribute is the whole binding: change it to a queue or timer trigger and the same method body runs on a different event. A small Program.cs alongside this builds the worker host and registers your services.
// dotnet add package Microsoft.Azure.Functions.Worker
// dotnet add package Microsoft.Azure.Functions.Worker.Extensions.Http
using System.Net;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
public sealed class Quotes
{
// The Function attribute names the function; the HttpTrigger attribute
// says what starts it. AuthorizationLevel.Function requires a key in
// the request, so this is not open to the internet.
[Function("quote")]
public HttpResponseData Run(
[HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequestData request)
{
HttpResponseData response = request.CreateResponse(HttpStatusCode.OK);
response.WriteString("1250");
return response;
}
}
Discussion