The best language for serverless applications in 2024

Vladimir Shchur
14 min readDec 7, 2023
Generated by AI

Introduction

I’ve been developing serverless applications for a while. The journey started from Azure Functions, followed by some work with Google Cloud Functions but for the last 4 years I stick to AWS Lambda. From my point of view AWS has the best developer experience, so I’ll be focusing on lambdas, although the ideas are generally applicable to other solutions as well.

For serverless beginners and clarity, let’s start from brief introduction to serverless and continue from there. There are dozens of definitions, but I’ll stick to the following — serverless application is an application that is built on serverless technologies of the chosen cloud provider. Simple as that, it means that there are many more serverless technologies other than just functions and it’s even not realistic to only use functions, you’ll need databases, queues, load balancers, reporting, etc. Still, functions (I’ll be calling them lambdas from now on) are the core and glue for all of them.

For AWS a list of the most often used serverless technologies includes S3, DynamoDB, API Gateway, Step Functions, SQS, SNS, AppSync, CloudWatch, IAM, EventBridge, the main characteristic of them would be “pay per usage”. There are some other services that have “serverless” in their name, but as long as they are not “pay per usage”, but rather “pay per time it’s enabled” they are not true serverless, since it does imply “paying for servers”.

To conclude the introduction let’s come up with some reasons why and when serverless applications are clear benefit to business, and also add some downsides of them. Take the numbers below not as ground truth, but as an approximate reference.

Why:

  • It’s cost effective for low and average load
  • It’s easily scalable in and out
  • It’s easy to build and maintain
  • It embraces “NoOps” approach — the same team can write, deploy and maintain code, infrastructure and pipelines, no extra DevOps (or whatever they call themselves) engineers are needed

When:

  • You are cloud native (not cloud agnostic)
  • You have uneven low load (up to 50 requests per second)
  • You have uneven average load (up to 500 requests per second) and you are able to process most requests quickly (in less than 50ms)
  • You don’t have long-running synchronous flows and long-standing persistent connections to other resources

Drawbacks:

  • You won’t have local debugging
  • It will be more expensive than containers at high load or consistent load
  • You’ll face some platform limitations (like input size, execution time, etc)
  • You’ll need to change your habits and mindset

Lambda

It’s time to describe the most important serverless service itself. In my opinion, it’s popularity really is based on the following design choices that were made in the beginning and still drive this service to success.

  • Simple Input -> Output model
  • Isolated invocations
  • Immediate auto-scaling
  • Native integrations to other services
  • Pay only for execution time
  • Easy deploy

Let’s go through this points one by one

Simple Input -> Output model

From my point of view this is the first and the main benefit of it’s success, and it’s not a coincidence. Such model is a simple function, it takes it’s roots from Functional Programming and this choice greatly simplifies reasoning and understanding of what Lambda is about — it simply takes an input (Request) and returns the output (Response). And since it’s just a function — it can be easily decomposed into other functions that can be decomposed further and further, which really leads to simple and easily scalable code.

Decomposition
Decomposition

There exist some deviations from this model, but I think it’s the wrong direction, since it goes against the core lambda principle. There is no need to define OOP abstractions, since it is just a function which depends on its input (and possibly on lambda context).

Isolated invocations

At a “cold” start, lambda is initialized and some static data can be created and used within next invocations. However, this shared data is not shared between running lambda instances and this simplifies things a lot — no concurrent issues can occur when multiple requests are executed in parallel. This means no traditional synchronization frameworks or http servers are required. You still can benefit from asynchronous programming by executing sub-tasks concurrently, but all of that should be done within a single function invocation.

Immediate auto-scaling

This is a most well-known killer feature of lambdas. If you served 1 req/seq and then suddenly 100 users make concurrent requests, 100 lambdas will get raised in no time. ALB and Kubernetes auto-scaling are not even close to that. There are some limits of this auto-scaling, it will grow by a fixed additional execution environment instances (burst quota units) per minute and overall amount is limited (read more), but it is still impressive (and became even more impressive few weeks ago). Raised lambdas can stay warm for free from about 10 minutes to about an hour, so it’s a huge benefit and you should leverage it in your design, for example by making periodic “ping” requests to lambdas to keep them warm.

Native integrations to other services

This is lambda bread and butter — they can be used as a glue between almost any two AWS services. Usually this is used to do some data-related tasks where you need to enrich, transform, store, process or explore data, but can also be used for purely operational tasks — calling some APIs, launching more EC2s, building your code, running deploy, etc. They are not useful for everything, because of input size, execution time, hardware and software limits, but there is definitely a lot they can do.

Pay only for execution time

Lambda has millisecond billing. Normally you’ll pay for cold start about once per hour, so you can still use regular performance tricks to shorten invocation time to reduce overall costs. Without much tuning you can expect responses up to 10ms if you manage to give responses by only using local memory cache. When you need to interact with other services be creative and utilize parallel asynchronous requests to reduce overall invocation time — this does pay off.

Easy deploy

It sounds small, but really is huge. I deploy lambdas almost every day and have zero downtime, since switch to a new version happens only when it’s up and running (well, there are some rare corner cases to be aware of). I use AWS CDK for deployment and highly recommend it, since language of your choice will lead you much further than YAML or specialized languages like HCL. Lambda can be deployed as a zip at supported runtime or as container, but the former is easier and will give better cold starts, so you should default to it and use container only when there are special requirements.

***

Now as we have covered the basics, let’s go to the meat and try to figure out what is the best language for writing lambdas. In order to do that we need to figure out the requirements, here is the list I suggest:

  1. Best compatibility with the cloud APIs
  2. Should be easy to follow Lambda principles
  3. Robust, something close to “if it compiles - it works”
  4. Easy to read/write code and make changes in future
  5. Easy to deploy

Since some of those concepts are quite opinionated I’ll use the least opinionated advice from the strongest general LLM at the moment i.e. GPT-4 and the strongest LLM with the ability of online search i.e. Bing search.

Best compatibility with the cloud APIs

This is the main point, and it will reduce the amount of choices to analyze further. When visiting supported runtimes, we’ll see the following platforms: Node.js, Python, Java, .NET, Go, Ruby. While it is possible to use anything else with custom runtime, it takes more infrastructure preparations and it will start slower, so I would not recommend that. Also, for all those platforms there is a dedicated AWS SDKs, so the competitors are all equal at this stage. I suspect that some other platforms will be supported in future, so the list is only valid at the time of writing (December 2023).
Since those are all platforms, the language list is a bit larger, it would be JavaScript, TypeScript, Python, Java, Kotlin, Scala, C#, F#, Go, Ruby.
Also, there is a project specifically for lambdas called PowerTools for Lambda that makes several infrastructure best practices easier to achieve.
So I’ll mark all the languages that are supported in PowerTools as winners of this round: JavaScript, TypeScript, Python, Java, Kotlin, Scala, C#, F#.

Should be easy to follow Lambda principles

Recall that lambda function is just an Input -> Output model, which is a pure function, so the more “functional” a language is the better. It’s a long way trying to figure out which languages are more or less functional, so I just asked separately GPT-4 and Bing, so here are there answers:
GPT-4:
JavaScript -> Python -> Go -> Ruby -> TypeScript -> Java -> C# -> Kotlin -> Scala -> F#
Bing:
Go -> Python -> Ruby -> JavaScript -> TypeScript -> Java -> C# -> Kotlin -> Scala -> F#
As unreliable as those tools are, the general idea is clear and I tend to mostly agree with them. Since the main task is to decompose main function to many more smaller functions in hierarchical way, we really need here the language that makes it simple and natural. Opinionated dependencies and abstractions baked in some of those languages simply get in the way.
With that I’ll take top 3 technologies here as winners, namely Kotlin, Scala and F#.

Robust, something close to “if it compiles — it works”

Serverless functions development is very different to the traditional development in a way that you can’t normally run it and debug locally. There exist some local tools, but things are more complicated with them than without them. Therefore you should rely mostly on compiler, tests and logs+metrics monitoring (in that particular order). From them compiler is the most important, since it will help you eliminate bugs as quickly as possible. You might have heard about “Make illegal states unrepresentable”, which is particularly important for lambdas. Again, let’s ask AI to order competitors based on robustness:
GPT-4:
JavaScript -> Python -> Ruby -> Go -> TypeScript -> Java -> Kotlin -> C# -> Scala -> F#
Bing:
JavaScript -> Ruby-> Python -> Go -> TypeScript -> Kotlin-> Java -> C# -> Scala -> F#
Again, even though several positions are arguable, I agree with the overall picture. And what I find interesting, there is evident correlation between language being functional and it’s robustness. I won’t dive deep into the reasons of that, just will say that it’s due to the quality of the language type system.
In this round the top 3 winners would be C#, Scala and F#.

Easy to read/write code and make changes in future

Although this is might not seem to be particularly related to serverless, I think it’s important. Once you start making serverless app, you’ll find that while you probably have the main lambda (like main API), there will be a lot of additional lambdas served as triggers, schedulers, transformers, extra API’s and you’ll need to make sure that after some time you’ll be able to understand what they are doing. Eventually you’ll end up with a serverless distributed monolith which might have to be split when overgrown. So, since most lambdas will be small, it’s expected that the code for them should be small as well. So the code of such lambdas should be concise and easily understandable. It’s time we ask AI about conciseness an readability (I asked GPT-4 about former and Bing about latter):
GPT-4:
Java → C# → Kotlin → Go → TypeScript → JavaScript → Ruby → Scala → F# → Python
Bing:
Go → TypeScript → JavaScript → Java → C# → Scala → F# → Kotlin → Ruby → Python
This time engines disagree a little bit with each other, GPT-4 penalizes verbosity and Bing penalizes complexity. I personally don’t like neither verbosity nor complexity, so I’ll just average their results.
This time the top 3 winners would be Ruby, F# and Python.

Easy to deploy

For AWS I recommend using CDK and similar tools for other clouds, since the idea of infrastructure as a code is the top, it will give you the most supportable solution in future. There is one caveat — make sure you update your CDK tools often, since if you do — you won’t have problems, otherwise if you leave it stale for years, be prepared for compatibility issues.
There are solutions like Terraform, SAM, Serverless framework, Pulumi and others, but CDK is free and gives you the full power of your chosen language, which is important to keep infrastructure code as clean as your business code.
CDK support is available for most of the competitors, but there are some notes:
- Ruby doesn’t support CDK at all
- Go support is only in development preview for now
- Scala CDK package is deprecated and I couldn’t google up any fresh examples for Scala
Since I don’t have any other requirements, all the other languages would be winners here, namely TypeScript, JavaScript, Java, C# , F#, Kotlin, Python.

Results

As you can see, the only language that excels in all rounds is F#. You might have guessed already that I’ve used F# in my latest work projects very successfully, and indeed, complicated distributed transaction flows were implemented in a serverless application with almost zero bugs. If you want to achieve the same quality with the help of F#, here are some handy pieces of advice I would give in the end.

  • Don’t create additional lambdas just because you heard that “lambdas should be small”, you’ll have enough of them anyway.
  • It is usually better to implement routing directly in code by matching request URL in one lambda than create lambda per API Gateway route, it will give make you code more manageable and will reduce the amount of cold starts.
  • Learn more about serverless architectures, for example from here.
  • F# modules are perfect for keeping singletons like database clients, http clients, memory cache, constants and loggers. Basically a lifetime of any object will be either singleton or per request.
  • F# has a great async story, you can use Task or Async to easily run parallel requests to reduce overall execution time.
  • To understand how to compose/decompose functions that can return errors you can refer to the ROP guide.
  • You will still need to mock some dependencies in your unit tests, to solve this you can use the following classic article, it works perfectly for lambdas.
  • Use a lot of monitoring, especially X-Ray integration, so all the parts of your serverless app are visible in one dashboard.
  • Use structured logging, this will help you find relevant logs in CloudWatch Insights. Together with monitoring they eliminate the need of local debugging.
  • Consider implementing optional logging based on level to reduce CloudWatch costs (details).
  • F# can be translated to other languages (JavaScript, Python and Rust) using Fable project, you might want to use that.

Where is the code? Show me the code!

Unfortunately, medium doesn’t support F# markup, so please bear the C#/TypeScript syntax highlighting.
The code below is very close to the production ready version we use. Example lambda function is presented as an API handler for the web requests going through the API Gateway.

Simple serverless application architecture

First, let’s define some response helpers, those are just pure functions in modules, just like 80% of code in typical F# application. Note, that zero types definitions are written, all of them are statically derived by compiler.

namespace Infrastructure.Common

open Amazon.Lambda.APIGatewayEvents
open System.Text.Json

module Response =
let jsonContentTypeHeader =
"Content-Type", "application/json; charset=UTF-8"

let defaultHeaders = [
"Access-Control-Max-Age", "3600"
"Access-Control-Allow-Headers", "*"
"Access-Control-Allow-Origin", "*"
"Access-Control-Allow-Methods", "OPTIONS,GET,PUT,POST,DELETE,PATCH,HEAD"
]

let make headers statusCode body =
APIGatewayProxyResponse(
Headers = dict (defaultHeaders @ headers),
StatusCode = statusCode,
Body = body
)

let ok () = make [] 200 null
let string value = make [] 200 value
let jsonString value = make [jsonContentTypeHeader] 200 value
let json value = JsonSerializer.Serialize(value) |> jsonString

let notFound () = make [] 404 null
let serverError value = make [jsonContentTypeHeader] 500 value

Next, here is an example lambda handler, note that business logic is a simple function, depending only on it’s parameters, no DI containers are required.

namespace MyApi

// Amazon namespaces
open Amazon.DynamoDBv2
open Amazon.Lambda.APIGatewayEvents
open Amazon.Lambda.Core
open Amazon.Lambda.Serialization.SystemTextJson
open Amazon.XRay.Recorder.Handlers.AwsSdk

// System namespaces
open System
open System.Threading.Tasks

// custom helpers to simplify returning responses (like Response.ok())
open Infrastructure.Common

// Choose the JSON serializer to use (usage based on AWS documentation)
[<assembly: LambdaSerializer(typeof<DefaultLambdaJsonSerializer>)>]
()

// environment that will be passed through the application (simplified)
// more details in https://www.bartoszsypytkowski.com/dealing-with-complex-dependency-injection-in-f/
type Env =
{
LambdaContext: ILambdaContext
Request: APIGatewayProxyRequest
DynamoDbClient: AmazonDynamoDBClient
SomeTableName: string
}

// Root module where all singleton dependencies are defined
module Root =
// XRay tracing for all SDK clients used in app
AWSSDKHandler.RegisterXRayForAllServices()
// Singleton db client
let dynamoDBClient = new AmazonDynamoDBClient()
let dynamoDbTable = Environment.GetEnvironmentVariable("MyTable")

// custom router
let handle (env: Env) =
task {
match env.Request.HttpMethod with
| "GET" ->
match env.Request.Path with
| "/v1.0/getEntities" ->
// business logic shoule reside inside
let! entities = Service.getEntities env
// serialize response
return Response.json entities
| _ ->
return Response.notFound()
| "POST" ->
match env.Request.Path with
| "/v1.0/updateEntity" ->
let entity = Json.deserialize env.Request.Body
// business logic should reside inside
do! Service.updateEntity env entity
// empty response
return Response.ok()
| _ ->
return Response.notFound()
| _ ->
return Response.notFound()
}

// This is the entry point type for AWS Lambda
type Function() =
// This is a function that will be called on each request
member this.FunctionHandler
(
request: APIGatewayProxyRequest,
context: ILambdaContext
): Task<APIGatewayProxyResponse> =
task {
// global try-catch for all unexpected errors
try
let env = {
Request = request
LambdaContext = context
DynamoDbClient = Root.dynamoDBClient
SomeTableName = Root.dynamoDbTable
}
return! Root.handle env
with ex ->
ex |> string |> context.Logger.LogError
return Response.serverError "Unexpected error"
}

I’d like to also show the CDK code for such function, i.e. DynamoDB table, API Gateway and AWS Lambda itself

namespace Deploy

open Amazon.CDK
open Amazon.CDK.AWS.APIGateway
open Amazon.CDK.AWS.DynamoDB
open Amazon.CDK.AWS.IAM
open Amazon.CDK.AWS.Lambda

// CDK is deployed as CloudFormation stack(s)
type MainStack(scope, id, props) as this =
inherit Stack(scope, id, props)

// create dynamodb table
let myTable =
Table(this, "my-table-id",
TableProps(
TableName = "MyTable",
BillingMode = BillingMode.PAY_PER_REQUEST,
// I prefer naming keys as PK and SK to be able to change exact format later
PartitionKey = Attribute (Name = "PK", Type = AttributeType.STRING),
SortKey = Attribute (Name = "SK", Type = AttributeType.STRING)
)
)
// create lambda function
let myApi =
Function(this, "my-api-id", FunctionProps(
Runtime = Runtime.DOTNET_6,
// Zip archive with the output of dotnet lambda package command
Code = Code.FromAsset("./assets/MyApi.zip"),
FunctionName = "MyApi",
// Format is {assembly name}::{namespace}.{class name}::{method name}
Handler = "MyApi::MyApi.Function::FunctionHandler",
// from my experience the best combination of price and performance
MemorySize = 2048.0,
// can be modified freely
Timeout = Duration.Minutes(4.0),
// pass environment variables to the function
Environment = dict [
"MyTable", myTable.TableName
],
// enable XRay tracing
Tracing = Tracing.ACTIVE
))

// grant lambda function access to dynamodb table with PartiQL
// PartiQL is my preferred way of communicating to DynamoDB
do myTable.Grant(myApi, [|"dynamodb:PartiQLSelect"; "dynamodb:PartiQLUpdate"|]) |> ignore

// Defines an API Gateway REST API with AWS Lambda proxy integration.
let myApiGateway =
LambdaRestApi(this, "my-api-gw-id", LambdaRestApiProps(
Handler = myApi,
RestApiName = "MyApiGateway",
DeployOptions = StageOptions(
// Enable XRay tracing
TracingEnabled = true
),
Policy = PolicyDocument(PolicyDocumentProps(Statements = [|
PolicyStatement(PolicyStatementProps(
Actions = [| "execute-api:Invoke" |],
Resources = [| "execute-api:/*/*/*" |],
Principals = [| AnyPrincipal() |],
Effect = Effect.ALLOW))
|]))
))

As you can see, the code is very natural, also ability to write both function and infrastructure in the same project enables some constants sharing (like names of environment variables) to reduce possible configuration bugs.

P.S. Performance

Developers like to talk about performance, so let’s talk about it as well. First of all, I must say that F# is based on .NET, and their combination is very fast in the 8th version. Second, if you are worried about the performance because of the high load — don’t go with lambdas, it will be expensive. Third, if you don’t have high load, but want low latency on every request (even cold start) and want to avoid JIT compilation, you still can go with .NET and NativeAOT with the release of .NET 8 Lambda runtime in February 2024.

***

Thanks for reading till the end! Please comment if any of the related topics is interesting to you so I can cover it future articles.
This article is a contribution to the F# advent calendar, check out other interesting topics there as well!

--

--