What serverless means

Serverless computing is a cloud model in which you deploy code or use a service without choosing, sizing or maintaining any servers. The name is misleading: there are still servers, in the provider's data centers. What disappears is your work with them. Three properties define the model:

  • No servers to manage. No operating system to patch, no capacity to plan, no instances to keep running.
  • Automatic scaling, down to zero. With no traffic nothing runs; with a thousand requests at once, the platform runs many copies in parallel.
  • Pay per use. You pay for requests and for the time your code actually runs, not for idle capacity.

Functions are the best-known serverless service, but the label also covers object storage, some databases (such as DynamoDB, or Aurora Serverless), queues and container platforms such as AWS Fargate, Azure Container Apps and Google Cloud Run. In the service models, serverless sits beyond platform as a service: you hand over even more, and in return you accept the platform's rules.

Functions

Function as a service (FaaS) runs small, single-purpose pieces of code — functions — in response to events. You upload the code and say which event should start it; the platform does the rest. Each provider has its own service:

AWS Microsoft Azure Google Cloud
Functions AWS Lambda Azure Functions Cloud Run functions (formerly Cloud Functions)
API gateway Amazon API Gateway Azure API Management API Gateway, Apigee
Queue Amazon Simple Queue Service (SQS) Queue Storage, Service Bus Pub/Sub
Event routing Amazon EventBridge Event Grid Eventarc
Serverless containers AWS Fargate Azure Container Apps Cloud Run

Product names in this area change more often than the ideas behind them; Google, for example, renamed Cloud Functions to Cloud Run functions. All of them support common languages such as Python, JavaScript (Node.js), Java and C#.

Writing a function

A function is an ordinary function in your language with a fixed signature. The platform calls it with an event (a dictionary describing what happened) and a context (information about the invocation), and uses the return value as the result. The handler below answers Hypertext Transfer Protocol (HTTP) requests for a price, in the response format that an application programming interface (API) gateway expects — here Amazon API Gateway in front of AWS Lambda. Run locally, a few lines at the bottom play the role of the platform:

import json

# Code outside the handler runs once per instance, during the cold start,
# and is reused by every request that instance handles afterward.
print("init: loading price list")
PRICES = {"coffee": 3.20, "tea": 2.50}


def handler(event, context):
    """Answer GET /prices/{product} in the response format an API gateway expects."""
    product = (event.get("pathParameters") or {}).get("product", "")
    if product in PRICES:
        status, body = 200, {"product": product, "price": PRICES[product]}
    else:
        status, body = 404, {"error": f"unknown product: {product}"}
    return {
        "statusCode": status,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps(body),
    }


# Testing locally: call the handler with sample events, as the platform would.
for name in ("coffee", "cake"):
    sample_event = {"httpMethod": "GET", "path": f"/prices/{name}",
                    "pathParameters": {"product": name}}
    response = handler(sample_event, context=None)
    print(response["statusCode"], response["body"])
init: loading price list
200 {"product": "coffee", "price": 3.2}
404 {"error": "unknown product: cake"}

Because a handler is just a function, you can unit-test it with sample events like this, without any cloud account. Once deployed, the AWS Command Line Interface (CLI) can invoke it directly. The session below needs an AWS account and a deployed function named get-price:

$ aws lambda invoke --function-name get-price \
    --cli-binary-format raw-in-base64-out \
    --payload '{"pathParameters": {"product": "coffee"}}' response.json
$ cat response.json
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
{"statusCode": 200, "headers": {"Content-Type": "application/json"}, "body": "{\"product\": \"coffee\", \"price\": 3.2}"}

Events and triggers

An event trigger is the configured link between a source of events and a function: "when this happens, run that". The most common sources are:

  • HTTP requests, usually through an API gateway: a web or mobile app calls a web address (Uniform Resource Locator, URL).
  • Queue messages: an order is placed on a queue, and a function processes it.
  • Storage events: a file is uploaded to a bucket, and a function makes a thumbnail or scans it.
  • Schedules: a timer, written like a cron expression, runs a cleanup job every night.
  • Database and platform events: a row changes, a new user signs up, a server stops.
Four event sources on the left — an HTTP request through an API gateway, a queue message, a file upload to storage and a schedule — each trigger the function in the middle. The function reads and writes a database and storage on the right. Between events, zero instances of the function run. HTTP → API gateway Queue message File uploaded Schedule (nightly) Function 0 … n instances Database Storage
Many kinds of events can trigger the same function. The function keeps nothing between runs; its state lives in a database or storage.

The API gateway

An API gateway is a managed front door for application programming interfaces (APIs). It receives HTTP requests on public URLs, checks them — authentication, request limits per client (throttling), input validation — and forwards each one to the right backend, often a function, as an event like the one in the example above. It turns the function's return value back into an HTTP response. Some platforms also offer a simpler direct URL per function, without the gateway's extra features. A direct function URL with authentication set to none is reachable by anyone on the internet, so the function itself must then check every request.

Cold starts, limits and state

Cold starts

When a request arrives and no instance of the function is ready, the platform must create one: reserve resources, load the runtime, load your code and run its initialization (the init line in the example). This delay before the first request is a cold start. It ranges from well under a second for a small Python function to several seconds for large packages or heavy frameworks. Afterward the instance stays warm for a while and handles further requests without that delay. Scaling up adds new instances, so a traffic spike causes a burst of cold starts.

Ways to reduce them:

  • Keep the deployment package small and import only what you need.
  • Do expensive setup (database connections, loading configuration) outside the handler, so warm requests reuse it.
  • Choose more memory: on several platforms the processor share grows with the memory setting, which also speeds up initialization.
  • Keep some instances warm on purpose: provisioned concurrency in AWS Lambda, always-ready instances in some Azure Functions plans, minimum instances in Cloud Run. This removes cold starts for that capacity, but you pay for it even when idle — partly giving up scale to zero.

Limits

Functions trade freedom for convenience, and the platform enforces limits. The exact numbers differ per provider and plan and change over time, so check the current documentation; at the time of writing AWS Lambda, for example, stops a function after at most 15 minutes. The usual limits are:

  • Maximum run time (timeout) per invocation, from minutes to an hour depending on the service and plan.
  • Memory per instance, which often also determines the processor share.
  • Package size of the deployed code and its dependencies (larger with container images).
  • Request and response size through an API gateway.
  • Concurrency: the number of instances running at once per account or function. It protects you, and the databases behind you, from runaway scaling.

Statelessness

Each invocation may run on a different instance, and instances are created and destroyed at any moment. A function must therefore be stateless: it cannot rely on anything kept in memory or on local disk from an earlier request. A shopping cart, a counter or an uploaded file must be stored in a database, a cache or object storage. Data cached in memory by a warm instance (like the price list above) is a bonus, never a guarantee.

Identity and permissions

Each function runs with its own identity (in Lambda an execution role); grant it only the actions and resources it needs, one role per function. A function that only reads one table should not be able to delete a bucket. A direct function URL with authentication set to none is reachable by anyone on the internet. See Identity and Access Management.

Event-driven architecture and queues

Serverless applications are usually event-driven: components do not call each other directly and wait, but publish events and messages that others react to. A web shop function that accepts an order puts a message on a queue and answers the customer immediately; separate functions then charge the payment, send the confirmation email and update the stock, each at its own pace.

A queue between the parts absorbs spikes (messages wait instead of overloading the next step), allows retries when a step fails, and lets parts be changed independently. Two consequences come with it:

  • Messages can arrive more than once. Most queues guarantee at-least-once delivery: after a timeout or retry, the same message may be delivered again. Handlers must be idempotent — processing a message twice must have the same effect as processing it once.
  • Failing messages need somewhere to go. After a set number of failed attempts, a message moves to a dead-letter queue, where someone can inspect it instead of it blocking the queue or retrying forever.

A common way to be idempotent is to remember which message IDs were already handled:

# A queue may deliver the same message more than once ("at least once").
messages = [
    {"id": "msg-1", "order": 1042, "amount": 59.90},
    {"id": "msg-2", "order": 1043, "amount": 24.00},
    {"id": "msg-1", "order": 1042, "amount": 59.90},  # delivered again after a retry
]

processed = set()  # in production: a database table with a conditional write


def handle(message):
    if message["id"] in processed:
        return f"skip   order {message['order']}: already processed"
    processed.add(message["id"])
    return f"charge order {message['order']}: {message['amount']:.2f}"


for message in messages:
    print(handle(message))
charge order 1042: 59.90
charge order 1043: 24.00
skip   order 1042: already processed

In a real function the set would live in a database, because the next delivery may reach a different instance — statelessness again. Without the check, the customer of order 1042 would be charged twice.

The pricing model

Function pricing usually has two parts: a price per million requests, and a price for compute measured in gigabyte-seconds — the memory you configured multiplied by the run time. Providers add a monthly free allowance, and related services (the API gateway, data transfer, logs) are billed separately. The comparison below uses invented round prices, not real ones, to show the shape of the trade-off against two always-on virtual machines:

# FICTITIOUS round prices for illustration only, not any provider's real prices.
PER_MILLION_REQUESTS = 0.25
PER_GB_SECOND = 0.00002
VM_PER_MONTH = 35.00          # one small virtual machine, running all month

MEMORY_GB = 0.5               # memory given to the function
DURATION_S = 0.2              # average run time per request


def function_cost(requests):
    request_cost = requests / 1_000_000 * PER_MILLION_REQUESTS
    compute_cost = requests * DURATION_S * MEMORY_GB * PER_GB_SECOND
    return request_cost + compute_cost


vms = 2 * VM_PER_MONTH        # two VMs, so one can fail
print(f"{'requests/month':>15} {'functions':>10} {'2 VMs':>8}")
for requests in (100_000, 1_000_000, 10_000_000, 100_000_000):
    print(f"{requests:>15,} {function_cost(requests):>10.2f} {vms:>8.2f}")

break_even = vms / function_cost(1)
print(f"Break-even at about {break_even / 1_000_000:.0f} million requests per month")
 requests/month  functions    2 VMs
        100,000       0.23    70.00
      1,000,000       2.25    70.00
     10,000,000      22.50    70.00
    100,000,000     225.00    70.00
Break-even at about 31 million requests per month

At low and irregular traffic, functions cost almost nothing, because idle time is free. As traffic becomes high and steady, the price per request keeps adding up while a server's price stays flat. Where the break-even lies depends entirely on real prices, memory and run time — halving the run time halves the compute part — so compare with the provider's pricing calculator, and include the operational work that serverless saves. See Cloud Costs and FinOps.

When serverless fits — and when it does not

Good fit Poor fit
Spiky or unpredictable traffic, long quiet periods Steady, high load around the clock
Event processing: uploads, queue messages, webhooks Long-running jobs that exceed the time limit, such as large video encodes
Glue between services, scheduled maintenance tasks Strict low-latency requirements where a cold start is unacceptable
Small APIs and prototypes that need to be live quickly Software that needs a specific operating system, a graphics processor (GPU) or long-lived connections
Teams that want to spend little time on operations Workloads that must move easily between providers

The last row is about vendor lock-in: function code itself is portable, but the triggers, event formats, permissions and surrounding services are specific to each provider. Serverless containers (Cloud Run, Azure Container Apps, Fargate, whose scaling you configure in Amazon Elastic Container Service (ECS) or Elastic Kubernetes Service (EKS)) are a middle ground: you package a normal container image and still get automatic scaling, often with longer time limits.

Observability and debugging

Without servers, there is nothing to log in to when something goes wrong. Everything you learn about a function comes from what it reports, and one user action may pass through an API gateway, three functions and two queues. That makes debugging harder than on a single server:

  • Logs: everything a function prints goes to the provider's log service (Amazon CloudWatch Logs, Azure Monitor and Application Insights, Cloud Logging). Log structured lines, for example JavaScript Object Notation (JSON), with a correlation ID that travels with the request, so one request can be followed across services.
  • Metrics: invocations, errors, duration, throttles and concurrency show trouble before users report it.
  • Distributed tracing (AWS X-Ray, Application Insights, Cloud Trace, or OpenTelemetry) draws the path of a request through all components and shows where the time went — including cold starts.
  • Local testing catches logic errors early: call handlers with sample events as in the example above. Emulators exist, but behavior around permissions, limits and real events is only fully visible in a test account.

Logging every request in detail costs money too; at high volumes the log bill can exceed the function bill. See Monitoring, Logging and Observability.