AWS Lambda for .NET Developers: Build, Deploy, and Optimize C# Serverless Functions

AWS Lambda

If you come from a .NET background, AWS Lambda can feel unfamiliar at first — there's no Program.cs hosting a web server, no IIS, no long-running process. Instead, your code runs only when triggered, for as long as it takes to finish, and then stops. This guide walks through building, testing, and deploying a real C# Lambda function, along with the .NET-specific details — cold starts, Native AOT, IAM, and cost — that matter once you move past "Hello World."

1. What AWS Lambda Actually Runs

A Lambda function is a single handler method that AWS invokes with an event payload and a context object, runs inside a managed execution environment, and then either keeps warm for the next invocation or gets torn down. For .NET, AWS maintains managed runtimes for .NET 6 and .NET 8, and also supports .NET 8 Native AOT, which compiles your function to a native executable instead of running on the standard CLR — this matters a lot for cold start time, covered in section 6.

2. Setting Up Your Project

Install the Lambda tooling for the .NET CLI once, globally:

dotnet tool install -g Amazon.Lambda.Tools

Then scaffold a new function project:

dotnet new lambda.EmptyFunction --name OrderProcessor.Lambda
cd OrderProcessor.Lambda/src/OrderProcessor.Lambda

This template gives you a Function.cs file, a .csproj, and an aws-lambda-tools-defaults.json file that stores your deployment settings so you don't have to retype them every time.

3. The Function Handler

Here's a realistic handler — not just string-echo, but one that parses a JSON payload, validates it, and returns a structured response, which is closer to what you'll actually ship:

using Amazon.Lambda.Core;
using System.Text.Json;

[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]

namespace OrderProcessor.Lambda;

public record OrderRequest(string OrderId, decimal Amount, string CustomerEmail);
public record OrderResponse(bool Success, string Message);

public class Function
{
    public OrderResponse FunctionHandler(OrderRequest request, ILambdaContext context)
    {
        context.Logger.LogInformation($"Processing order {request.OrderId} for {request.CustomerEmail}");

        if (request.Amount <= 0)
        {
            return new OrderResponse(false, "Order amount must be greater than zero.");
        }

        // Business logic goes here — save to DynamoDB, call an API, publish to SNS, etc.

        return new OrderResponse(true, $"Order {request.OrderId} processed successfully.");
    }
}

Two things worth calling out for .NET developers specifically: ILambdaContext replaces the ASP.NET HttpContext you're used to — it gives you the remaining execution time, request ID, and a logger, but nothing about HTTP unless you're behind API Gateway. And the [assembly: LambdaSerializer] attribute is easy to forget; without it, Lambda won't know how to deserialize your event into OrderRequest.

4. Deploying the Function

Deploy directly from the CLI without touching the AWS Console:

dotnet lambda deploy-function OrderProcessor-Prod \
  --function-role OrderProcessorLambdaRole

The tool will prompt for a few settings the first time (memory, timeout, IAM role) and then save them into aws-lambda-tools-defaults.json so future deploys are a single command:

dotnet lambda deploy-function

5. Testing Without Deploying Every Time

Redeploying for every small change is slow. Test locally first with a sample event file:

dotnet lambda invoke-function OrderProcessor-Prod --payload "{ \"OrderId\": \"1001\", \"Amount\": 49.99, \"CustomerEmail\": \"test@example.com\" }"

For faster local iteration without even hitting AWS, the Amazon.Lambda.TestTool package spins up a local Lambda runtime emulator you can debug directly from Visual Studio with breakpoints — genuinely useful once your handler grows past a few lines.

6. Cold Starts and Native AOT

This is the part most .NET-to-Lambda guides skip, and it's the part that actually matters in production. A standard .NET 8 Lambda function running on the managed CLR runtime typically has a cold start in the 150–400ms range depending on package size and dependency injection setup. That's usually fine for background jobs, but it's noticeable if the function sits behind API Gateway serving user-facing requests.

.NET 8's Native AOT support for Lambda compiles your function ahead of time into a native binary, which cuts cold starts down significantly — often into double digits (sub-100ms) — because there's no JIT compilation happening on first invocation. The trade-off: Native AOT has stricter reflection limitations, so libraries that rely heavily on runtime reflection (some serialization or DI containers) may need adjustment. To use it:

dotnet new lambda.NativeAOT --name OrderProcessor.Lambda

If your function is latency-sensitive and API Gateway-facing, start with Native AOT from day one rather than migrating later — the DI and serialization adjustments are much easier to make before the codebase grows.

7. IAM: The Part That Actually Blocks You

The most common "it works locally but not in Lambda" issue for developers new to AWS isn't code — it's permissions. Your function's execution role needs explicit permission for everything it touches. A function writing to DynamoDB and reading from S3 needs a policy like this attached to its execution role:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem", "dynamodb:GetItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/Orders"
    },
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::order-attachments/*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*"
    }
  ]
}

That last block — CloudWatch Logs permissions — is easy to forget and is the reason a function can "run successfully" while you see absolutely nothing in the logs to debug it with.

8. Configuration and Environment Variables

Skip hardcoded connection strings. Set environment variables in aws-lambda-tools-defaults.json or the console, and read them the same way you would in any .NET app:

var tableName = Environment.GetEnvironmentVariable("ORDERS_TABLE_NAME")
    ?? throw new InvalidOperationException("ORDERS_TABLE_NAME is not configured.");

For anything sensitive — API keys, connection strings with credentials — use AWS Secrets Manager or Parameter Store instead of plain environment variables, and grant the execution role secretsmanager:GetSecretValue scoped to just that secret's ARN.

9. Triggering From API Gateway

To expose the function as an HTTP endpoint, change the handler signature to accept an API Gateway proxy request instead of your own custom type:

using Amazon.Lambda.APIGatewayEvents;

public APIGatewayProxyResponse FunctionHandler(APIGatewayProxyRequest request, ILambdaContext context)
{
    var order = JsonSerializer.Deserialize<OrderRequest>(request.Body);

    return new APIGatewayProxyResponse
    {
        StatusCode = 200,
        Body = JsonSerializer.Serialize(new { success = true }),
        Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
    };
}

10. What This Actually Costs

Lambda pricing is based on requests and execution duration (GB-seconds — memory allocated multiplied by execution time). A function with 256MB memory that runs for 200ms, called 1 million times a month, costs roughly $0.83 in compute plus $0.20 per million requests — a few dollars a month for moderate traffic. The number that surprises people isn't Lambda itself; it's forgetting that a Lambda function inside a VPC needs a NAT Gateway for outbound internet access, and NAT Gateway's hourly cost can dwarf the Lambda bill for a low-traffic function. If your function doesn't need VPC access (no RDS, no internal-only resources), skip the VPC entirely and avoid that cost.

Common Mistakes .NET Developers Make With Lambda

  • Injecting a fresh HttpClient per invocation instead of a static/singleton one — this exhausts sockets under load exactly like it would in ASP.NET, just less visibly.
  • Expecting static fields to reset between invocations. They don't — Lambda reuses warm execution environments, so static state persists across calls until the environment is recycled. Useful for caching, dangerous if you assumed a clean slate every time.
  • Setting the timeout too high "to be safe." A stuck function now costs money for the full timeout duration on every failure instead of failing fast.
  • Forgetting that Lambda's /tmp directory is the only writable local storage, and it's capped (512MB by default, up to 10GB configurable) and not guaranteed to persist between invocations.

Final Thoughts

Lambda isn't a replacement for ASP.NET Core in every scenario — for a full API with dozens of endpoints, a container on ECS or a traditional API is often simpler to reason about. Where Lambda earns its place is event-driven work: processing an S3 upload, reacting to a DynamoDB stream, running a scheduled job, or handling bursty, infrequent traffic where paying only per invocation beats running a server around the clock. Start with the managed .NET 8 runtime to get comfortable with the model, then move to Native AOT once latency actually matters for your use case.

Waqar Kabir

Certified .Net Specialist

Post a Comment

Previous Post Next Post